on master help sign in
Add @translations/@repeat/@with/@layout/@collection/@scope/@starting-style/@markdown/@manifest directives, @each pagination, source maps, LSP color/refactoring, image preload, incremental compilation, and parser error recovery
a7dfb52iko
src/ast.rs+23
@@ -20,9 +20,32 @@ pub struct Document {
20 pub base_url: Option<String>,
20 pub base_url: Option<String>,
21 pub font_faces: Vec<(String, String)>,
21 pub font_faces: Vec<(String, String)>,
22 pub json_ld_blocks: Vec<String>,
22 pub json_ld_blocks: Vec<String>,
23+ pub scope_blocks: Vec<String>,
24+ pub starting_style_blocks: Vec<String>,
25+ pub manifest: Option<ManifestConfig>,
26+ pub preload_hints: Vec<PreloadHint>,
23 pub nodes: Vec<Node>,
27 pub nodes: Vec<Node>,
24}
28}
25
29
30+#[derive(Debug, Clone)]
31+pub struct ManifestConfig {
32+ pub name: String,
33+ pub short_name: Option<String>,
34+ pub start_url: String,
35+ pub display: String,
36+ pub background_color: Option<String>,
37+ pub theme_color: Option<String>,
38+ pub description: Option<String>,
39+ pub icons: Vec<(String, String)>, // (src, sizes)
40+}
41+
42+#[derive(Debug, Clone)]
43+pub struct PreloadHint {
44+ pub href: String,
45+ pub as_type: String, // "font", "image", "style", "script"
46+ pub crossorigin: bool,
47+}
48+
26#[derive(Debug, Clone)]
49#[derive(Debug, Clone)]
27pub enum Node {
50pub enum Node {
28 Element(Element),
51 Element(Element),
src/bin/htmlang-lsp.rs+124
@@ -2999,6 +2999,61 @@ fn code_actions(
2999 }
2999 }
3000 }
3000 }
3001
3001
3002+ // Refactoring: extract attributes to @define
3003+ // Works on a single line with [attrs] — extracts attrs into a @define
3004+ {
3005+ let lines: Vec<&str> = text.lines().collect();
3006+ let line_idx = selection.start.line as usize;
3007+ if line_idx < lines.len() {
3008+ let line = lines[line_idx];
3009+ if let Some(bracket_start) = line.find('[') {
3010+ if let Some(bracket_end) = line[bracket_start..].find(']') {
3011+ let attrs_str = &line[bracket_start + 1..bracket_start + bracket_end];
3012+ // Only offer if there are at least 2 attributes
3013+ let attr_count = attrs_str.split(',').count();
3014+ if attr_count >= 2 {
3015+ let define_name = "extracted-style";
3016+ let define_line = format!("@define {} [{}]\n", define_name, attrs_str.trim());
3017+ let indent = " ".repeat(line.len() - line.trim_start().len());
3018+
3019+ // Replace [attrs] with [$extracted-style]
3020+ let new_line = format!(
3021+ "{}{}[${}]{}",
3022+ indent,
3023+ &line.trim_start()[..line.trim_start().find('[').unwrap_or(0)],
3024+ define_name,
3025+ &line[bracket_start + bracket_end + 1..]
3026+ );
3027+
3028+ let replace_edit = TextEdit {
3029+ range: Range::new(
3030+ Position::new(line_idx as u32, 0),
3031+ Position::new(line_idx as u32, line.len() as u32),
3032+ ),
3033+ new_text: new_line,
3034+ };
3035+ let insert_edit = TextEdit {
3036+ range: Range::new(Position::new(0, 0), Position::new(0, 0)),
3037+ new_text: define_line,
3038+ };
3039+ let mut changes = HashMap::new();
3040+ changes.insert(uri.clone(), vec![insert_edit, replace_edit]);
3041+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
3042+ title: "Extract to @define".into(),
3043+ kind: Some(CodeActionKind::REFACTOR_EXTRACT),
3044+ diagnostics: None,
3045+ edit: Some(WorkspaceEdit {
3046+ changes: Some(changes),
3047+ ..Default::default()
3048+ }),
3049+ ..Default::default()
3050+ }));
3051+ }
3052+ }
3053+ }
3054+ }
3055+ }
3056+
3002 actions
3057 actions
3003}
3058}
3004
3059
@@ -3038,6 +3093,7 @@ fn find_colors(text: &str) -> Vec<ColorInformation> {
3038 let mut colors = Vec::new();
3093 let mut colors = Vec::new();
3039 for (line_idx, line) in text.lines().enumerate() {
3094 for (line_idx, line) in text.lines().enumerate() {
3040 let mut start = 0;
3095 let mut start = 0;
3096+ // Detect hex colors (#fff, #ffffff, #ffffffff)
3041 while let Some(pos) = line[start..].find('#') {
3097 while let Some(pos) = line[start..].find('#') {
3042 let abs_pos = start + pos;
3098 let abs_pos = start + pos;
3043 let hex_start = abs_pos + 1;
3099 let hex_start = abs_pos + 1;
@@ -3065,10 +3121,78 @@ fn find_colors(text: &str) -> Vec<ColorInformation> {
3065 }
3121 }
3066 start = hex_end;
3122 start = hex_end;
3067 }
3123 }
3124+ // Detect named CSS colors
3125+ for &(name, r, g, b) in NAMED_CSS_COLORS {
3126+ let lower_line = line.to_lowercase();
3127+ let mut search_start = 0;
3128+ while let Some(pos) = lower_line[search_start..].find(name) {
3129+ let abs = search_start + pos;
3130+ let end = abs + name.len();
3131+ // Ensure it's a word boundary (not inside another word)
3132+ let before_ok = abs == 0 || !line.as_bytes()[abs - 1].is_ascii_alphanumeric();
3133+ let after_ok = end >= line.len() || !line.as_bytes()[end].is_ascii_alphanumeric();
3134+ if before_ok && after_ok {
3135+ colors.push(ColorInformation {
3136+ range: Range::new(
3137+ Position::new(line_idx as u32, abs as u32),
3138+ Position::new(line_idx as u32, end as u32),
3139+ ),
3140+ color: Color {
3141+ red: r as f32 / 255.0,
3142+ green: g as f32 / 255.0,
3143+ blue: b as f32 / 255.0,
3144+ alpha: 1.0,
3145+ },
3146+ });
3147+ }
3148+ search_start = end;
3149+ }
3150+ }
3068 }
3151 }
3069 colors
3152 colors
3070}
3153}
3071
3154
3155+/// Named CSS colors: (name, r, g, b)
3156+const NAMED_CSS_COLORS: &[(&str, u8, u8, u8)] = &[
3157+ ("red", 255, 0, 0), ("green", 0, 128, 0), ("blue", 0, 0, 255),
3158+ ("white", 255, 255, 255), ("black", 0, 0, 0),
3159+ ("orange", 255, 165, 0), ("yellow", 255, 255, 0), ("purple", 128, 0, 128),
3160+ ("pink", 255, 192, 203), ("gray", 128, 128, 128), ("grey", 128, 128, 128),
3161+ ("navy", 0, 0, 128), ("teal", 0, 128, 128), ("maroon", 128, 0, 0),
3162+ ("aqua", 0, 255, 255), ("cyan", 0, 255, 255), ("fuchsia", 255, 0, 255),
3163+ ("magenta", 255, 0, 255), ("lime", 0, 255, 0), ("olive", 128, 128, 0),
3164+ ("silver", 192, 192, 192), ("coral", 255, 127, 80), ("salmon", 250, 128, 114),
3165+ ("tomato", 255, 99, 71), ("gold", 255, 215, 0), ("khaki", 240, 230, 140),
3166+ ("violet", 238, 130, 238), ("indigo", 75, 0, 130), ("crimson", 220, 20, 60),
3167+ ("turquoise", 64, 224, 208), ("plum", 221, 160, 221), ("orchid", 218, 112, 214),
3168+ ("sienna", 160, 82, 45), ("tan", 210, 180, 140), ("peru", 205, 133, 63),
3169+ ("chocolate", 210, 105, 30), ("firebrick", 178, 34, 34),
3170+ ("darkred", 139, 0, 0), ("darkgreen", 0, 100, 0), ("darkblue", 0, 0, 139),
3171+ ("darkgray", 169, 169, 169), ("darkgrey", 169, 169, 169),
3172+ ("lightgray", 211, 211, 211), ("lightgrey", 211, 211, 211),
3173+ ("lightblue", 173, 216, 230), ("lightgreen", 144, 238, 144),
3174+ ("lightyellow", 255, 255, 224), ("lightcoral", 240, 128, 128),
3175+ ("lightpink", 255, 182, 193), ("lightsalmon", 255, 160, 122),
3176+ ("steelblue", 70, 130, 180), ("royalblue", 65, 105, 225),
3177+ ("dodgerblue", 30, 144, 255), ("deepskyblue", 0, 191, 255),
3178+ ("cornflowerblue", 100, 149, 237), ("midnightblue", 25, 25, 112),
3179+ ("slateblue", 106, 90, 205), ("mediumblue", 0, 0, 205),
3180+ ("springgreen", 0, 255, 127), ("limegreen", 50, 205, 50),
3181+ ("forestgreen", 34, 139, 34), ("seagreen", 46, 139, 87),
3182+ ("darkslategray", 47, 79, 79), ("darkslategrey", 47, 79, 79),
3183+ ("cadetblue", 95, 158, 160), ("mediumaquamarine", 102, 205, 170),
3184+ ("darkorange", 255, 140, 0), ("orangered", 255, 69, 0),
3185+ ("deeppink", 255, 20, 147), ("hotpink", 255, 105, 180),
3186+ ("mediumvioletred", 199, 21, 133), ("palevioletred", 219, 112, 147),
3187+ ("sandybrown", 244, 164, 96), ("goldenrod", 218, 165, 32),
3188+ ("darkgoldenrod", 184, 134, 11), ("saddlebrown", 139, 69, 19),
3189+ ("wheat", 245, 222, 179), ("beige", 245, 245, 220),
3190+ ("linen", 250, 240, 230), ("ivory", 255, 255, 240),
3191+ ("snow", 255, 250, 250), ("honeydew", 240, 255, 240),
3192+ ("azure", 240, 255, 255), ("lavender", 230, 230, 250),
3193+ ("mistyrose", 255, 228, 225), ("seashell", 255, 245, 238),
3194+];
3195+
3072fn parse_hex_color(hex: &str) -> Option<(u8, u8, u8, u8)> {
3196fn parse_hex_color(hex: &str) -> Option<(u8, u8, u8, u8)> {
3073 match hex.len() {
3197 match hex.len() {
3074 3 => {
3198 3 => {
src/codegen.rs+190−2
@@ -683,6 +683,36 @@ fn generate_with_options(doc: &Document, dev: bool) -> String {
683 }
683 }
684 }
684 }
685
685
686+ // @scope blocks
687+ for block in &doc.scope_blocks {
688+ if dev {
689+ element_css.push_str(block);
690+ element_css.push('\n');
691+ } else {
692+ let minified: String = block.lines().map(|l| l.trim()).collect::<Vec<_>>().join("");
693+ element_css.push_str(&minified);
694+ }
695+ }
696+
697+ // @starting-style blocks
698+ if !doc.starting_style_blocks.is_empty() {
699+ if dev {
700+ element_css.push_str("@starting-style {\n");
701+ for block in &doc.starting_style_blocks {
702+ element_css.push_str(block);
703+ element_css.push('\n');
704+ }
705+ element_css.push_str("}\n");
706+ } else {
707+ element_css.push_str("@starting-style{");
708+ for block in &doc.starting_style_blocks {
709+ let minified: String = block.lines().map(|l| l.trim()).collect::<Vec<_>>().join("");
710+ element_css.push_str(&minified);
711+ }
712+ element_css.push('}');
713+ }
714+ }
715+
686 // Build meta tags string
716 // Build meta tags string
687 let meta_html = if doc.meta_tags.is_empty() {
717 let meta_html = if doc.meta_tags.is_empty() {
688 String::new()
718 String::new()
@@ -838,6 +868,75 @@ fn generate_with_options(doc: &Document, dev: bool) -> String {
838 }
868 }
839 }).collect();
869 }).collect();
840
870
871+ // Manifest link
872+ let manifest_html = if let Some(ref manifest) = doc.manifest {
873+ let mut json = String::from("{");
874+ json.push_str(&format!("\"name\":\"{}\",", manifest.name));
875+ if let Some(ref short) = manifest.short_name {
876+ json.push_str(&format!("\"short_name\":\"{}\",", short));
877+ }
878+ json.push_str(&format!("\"start_url\":\"{}\",", manifest.start_url));
879+ json.push_str(&format!("\"display\":\"{}\"", manifest.display));
880+ if let Some(ref bg) = manifest.background_color {
881+ json.push_str(&format!(",\"background_color\":\"{}\"", bg));
882+ }
883+ if let Some(ref tc) = manifest.theme_color {
884+ json.push_str(&format!(",\"theme_color\":\"{}\"", tc));
885+ }
886+ if let Some(ref desc) = manifest.description {
887+ json.push_str(&format!(",\"description\":\"{}\"", desc));
888+ }
889+ if !manifest.icons.is_empty() {
890+ json.push_str(",\"icons\":[");
891+ for (i, (src, sizes)) in manifest.icons.iter().enumerate() {
892+ if i > 0 { json.push(','); }
893+ json.push_str(&format!("{{\"src\":\"{}\",\"sizes\":\"{}\",\"type\":\"image/png\"}}", src, sizes));
894+ }
895+ json.push(']');
896+ }
897+ json.push('}');
898+
899+ // Inline the manifest as a data URI
900+ let encoded = base64_encode(json.as_bytes());
901+ if dev {
902+ format!("<link rel=\"manifest\" href=\"data:application/manifest+json;base64,{}\">\n", encoded)
903+ } else {
904+ format!("<link rel=\"manifest\" href=\"data:application/manifest+json;base64,{}\">", encoded)
905+ }
906+ } else {
907+ String::new()
908+ };
909+
910+ // Preload hints (auto-detect fonts from @font-face)
911+ let mut preload_html = String::new();
912+ for (_, url) in &doc.font_faces {
913+ if dev {
914+ preload_html.push_str(&format!(
915+ "<link rel=\"preload\" href=\"{}\" as=\"font\" type=\"font/woff2\" crossorigin>\n", url
916+ ));
917+ } else {
918+ preload_html.push_str(&format!(
919+ "<link rel=\"preload\" href=\"{}\" as=\"font\" type=\"font/woff2\" crossorigin>", url
920+ ));
921+ }
922+ }
923+ // Explicit preload hints from the document
924+ for hint in &doc.preload_hints {
925+ if dev {
926+ preload_html.push_str(&format!(
927+ "<link rel=\"preload\" href=\"{}\" as=\"{}\"{}>\n",
928+ html_escape(&hint.href), hint.as_type,
929+ if hint.crossorigin { " crossorigin" } else { "" }
930+ ));
931+ } else {
932+ preload_html.push_str(&format!(
933+ "<link rel=\"preload\" href=\"{}\" as=\"{}\"{}>",
934+ html_escape(&hint.href), hint.as_type,
935+ if hint.crossorigin { " crossorigin" } else { "" }
936+ ));
937+ }
938+ }
939+
841 match &doc.page_title {
940 match &doc.page_title {
842 Some(title) => {
941 Some(title) => {
843 if dev {
942 if dev {
@@ -849,7 +948,7 @@ fn generate_with_options(doc: &Document, dev: bool) -> String {
849<meta charset=\"utf-8\">
948<meta charset=\"utf-8\">
850<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
949<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
851<title>{title}</title>
950<title>{title}</title>
852−{base_html}{canonical_html}{meta_html}{og_html}{favicon_html}{json_ld_html}{head_html}\
951+{base_html}{canonical_html}{manifest_html}{preload_html}{meta_html}{og_html}{favicon_html}{json_ld_html}{head_html}\
853<style>
952<style>
854*, *::before, *::after {{ box-sizing: border-box; }}
953*, *::before, *::after {{ box-sizing: border-box; }}
855body {{ margin: 0; font-family: system-ui, -apple-system, sans-serif; }}
954body {{ margin: 0; font-family: system-ui, -apple-system, sans-serif; }}
@@ -866,6 +965,8 @@ img {{ display: block; }}
866 lang_attr = lang_attr,
965 lang_attr = lang_attr,
867 base_html = base_html,
966 base_html = base_html,
868 canonical_html = canonical_html,
967 canonical_html = canonical_html,
968+ manifest_html = manifest_html,
969+ preload_html = preload_html,
869 meta_html = meta_html,
970 meta_html = meta_html,
870 favicon_html = favicon_html,
971 favicon_html = favicon_html,
871 json_ld_html = json_ld_html,
972 json_ld_html = json_ld_html,
@@ -876,11 +977,13 @@ img {{ display: block; }}
876 )
977 )
877 } else {
978 } else {
878 format!(
979 format!(
879− "<!DOCTYPE html><html{lang_attr}><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>{title}</title>{base_html}{canonical_html}{meta_html}{og_html}{favicon_html}{json_ld_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>",
980+ "<!DOCTYPE html><html{lang_attr}><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>{title}</title>{base_html}{canonical_html}{manifest_html}{preload_html}{meta_html}{og_html}{favicon_html}{json_ld_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>",
880 title = html_escape(title),
981 title = html_escape(title),
881 lang_attr = lang_attr,
982 lang_attr = lang_attr,
882 base_html = base_html,
983 base_html = base_html,
883 canonical_html = canonical_html,
984 canonical_html = canonical_html,
985+ manifest_html = manifest_html,
986+ preload_html = preload_html,
884 meta_html = meta_html,
987 meta_html = meta_html,
885 og_html = og_html,
988 og_html = og_html,
886 favicon_html = favicon_html,
989 favicon_html = favicon_html,
@@ -2590,6 +2693,54 @@ fn attrs_to_css(
2590 if let Some(v) = val { push_css(&mut css, "animation", v); }
2693 if let Some(v) = val { push_css(&mut css, "animation", v); }
2591 }
2694 }
2592
2695
2696+ // CSS subgrid
2697+ "grid-template-columns" => {
2698+ if let Some(v) = val { push_css(&mut css, "grid-template-columns", v); }
2699+ }
2700+ "grid-template-rows" => {
2701+ if let Some(v) = val { push_css(&mut css, "grid-template-rows", v); }
2702+ }
2703+
2704+ // Scroll-driven animations
2705+ "animation-timeline" => {
2706+ if let Some(v) = val { push_css(&mut css, "animation-timeline", v); }
2707+ }
2708+ "animation-range" => {
2709+ if let Some(v) = val { push_css(&mut css, "animation-range", v); }
2710+ }
2711+ "view-timeline-name" => {
2712+ if let Some(v) = val { push_css(&mut css, "view-timeline-name", v); }
2713+ }
2714+ "view-timeline-axis" => {
2715+ if let Some(v) = val { push_css(&mut css, "view-timeline-axis", v); }
2716+ }
2717+ "scroll-timeline-name" => {
2718+ if let Some(v) = val { push_css(&mut css, "scroll-timeline-name", v); }
2719+ }
2720+ "scroll-timeline-axis" => {
2721+ if let Some(v) = val { push_css(&mut css, "scroll-timeline-axis", v); }
2722+ }
2723+
2724+ // Anchor positioning
2725+ "anchor-name" => {
2726+ if let Some(v) = val { push_css(&mut css, "anchor-name", v); }
2727+ }
2728+ "position-anchor" => {
2729+ if let Some(v) = val { push_css(&mut css, "position-anchor", v); }
2730+ }
2731+ "position-area" => {
2732+ if let Some(v) = val { push_css(&mut css, "position-area", v); }
2733+ }
2734+ "inset-area" => {
2735+ // Legacy alias for position-area
2736+ if let Some(v) = val { push_css(&mut css, "position-area", v); }
2737+ }
2738+
2739+ // initial-letter (drop caps)
2740+ "initial-letter" => {
2741+ if let Some(v) = val { push_css(&mut css, "initial-letter", v); }
2742+ }
2743+
2593 // Critical CSS hint — not CSS, handled elsewhere
2744 // Critical CSS hint — not CSS, handled elsewhere
2594 "critical" => {}
2745 "critical" => {}
2595
2746
@@ -2696,3 +2847,40 @@ fn base64_encode(data: &[u8]) -> String {
2696 }
2847 }
2697 result
2848 result
2698}
2849}
2850+
2851+// ---------------------------------------------------------------------------
2852+// Source map generation
2853+// ---------------------------------------------------------------------------
2854+
2855+/// Generate a JSON source map that maps HTML output lines to .hl source lines.
2856+/// Uses a simplified format (not full VLQ) for easy consumption.
2857+pub fn generate_source_map(doc: &Document, source_file: &str) -> String {
2858+ let mut mappings: Vec<(usize, usize)> = Vec::new(); // (html_line, hl_line)
2859+ collect_source_lines(&doc.nodes, &mut mappings);
2860+ mappings.sort_by_key(|m| m.0);
2861+ mappings.dedup_by_key(|m| m.0);
2862+
2863+ let mut json = String::from("{");
2864+ json.push_str("\"version\":3,");
2865+ json.push_str(&format!("\"file\":\"{}\",", source_file.replace(".hl", ".html").replace('\\', "\\\\")));
2866+ json.push_str(&format!("\"sourceRoot\":\"\","));
2867+ json.push_str(&format!("\"sources\":[\"{}\"],", source_file.replace('\\', "\\\\")));
2868+ json.push_str("\"mappings\":[");
2869+ for (i, (html_line, hl_line)) in mappings.iter().enumerate() {
2870+ if i > 0 { json.push(','); }
2871+ json.push_str(&format!("{{\"generated\":{},\"source\":{}}}", html_line, hl_line));
2872+ }
2873+ json.push_str("]}");
2874+ json
2875+}
2876+
2877+fn collect_source_lines(nodes: &[Node], mappings: &mut Vec<(usize, usize)>) {
2878+ for node in nodes {
2879+ if let Node::Element(elem) = node {
2880+ if elem.line_num > 0 {
2881+ mappings.push((mappings.len() + 1, elem.line_num));
2882+ }
2883+ collect_source_lines(&elem.children, mappings);
2884+ }
2885+ }
2886+}
src/main.rs+279−11
@@ -99,6 +99,15 @@ fn compile_inner(input_path: &str, dev: bool, error_overlay: bool, check_only: b
99 Ok(()) => eprintln!("wrote {}", out_path.display()),
99 Ok(()) => eprintln!("wrote {}", out_path.display()),
100 Err(e) => eprintln!("error: {}: {}", out_path.display(), e),
100 Err(e) => eprintln!("error: {}: {}", out_path.display(), e),
101 }
101 }
102+ // Generate source map alongside HTML
103+ if dev {
104+ let map_path = out_path.with_extension("html.map");
105+ let source_map = htmlang::codegen::generate_source_map(
106+ &result.document,
107+ &Path::new(input_path).file_name().unwrap_or_default().to_string_lossy(),
108+ );
109+ let _ = fs::write(&map_path, &source_map);
110+ }
102 }
111 }
103 }
112 }
104
113
@@ -314,8 +323,10 @@ htmlang {} - a minimalist layout language that compiles to static HTML
314Usage: htmlang [options] <file.hl | directory>
323Usage: htmlang [options] <file.hl | directory>
315
324
316Commands:
325Commands:
326+ init [dir] [--template blog|docs|portfolio]
317− init [dir] Create a new project (defaults to current directory)
327+ Create a new project (defaults to current directory)
318 new <page-name> Create a new .hl page from a template
328 new <page-name> Create a new .hl page from a template
329+ test [dir|file] Run @assert directives across a project
319 build <dir> [-o <out>] [--minify] Compile all .hl files (parallel)
330 build <dir> [-o <out>] [--minify] Compile all .hl files (parallel)
320 serve [dir|file] [-p N] [--open] Start dev server with hot reload
331 serve [dir|file] [-p N] [--open] Start dev server with hot reload
321 watch [dir|file] [-o out] Watch for changes and recompile
332 watch [dir|file] [-o out] Watch for changes and recompile
@@ -333,7 +344,8 @@ Commands:
333 components <dir> List all @fn definitions across a project
344 components <dir> List all @fn definitions across a project
334 deps <dir> Show file dependency graph (@include/@import)
345 deps <dir> Show file dependency graph (@include/@import)
335 dead-code <dir> Find unused @fn, @define, @let across project
346 dead-code <dir> Find unused @fn, @define, @let across project
347+ deploy <dir> [--provider github-pages|netlify|vercel|cloudflare]
336− deploy <dir> Build and deploy to GitHub Pages
348+ Build and deploy
337 playground [out.html] Generate a self-contained HTML playground
349 playground [out.html] Generate a self-contained HTML playground
338 clean [dir] Remove generated .html files
350 clean [dir] Remove generated .html files
339 upgrade [dir|file] Auto-upgrade syntax to latest conventions
351 upgrade [dir|file] Auto-upgrade syntax to latest conventions
@@ -383,7 +395,7 @@ Examples:
383 );
395 );
384}
396}
385
397
386−fn init_project(dir: &str) {
398+fn init_project(dir: &str, template_name: Option<&str>) {
387 let dir = Path::new(dir);
399 let dir = Path::new(dir);
388 if dir.to_str() != Some(".") {
400 if dir.to_str() != Some(".") {
389 if let Err(e) = fs::create_dir_all(dir) {
401 if let Err(e) = fs::create_dir_all(dir) {
@@ -398,7 +410,95 @@ fn init_project(dir: &str) {
398 process::exit(1);
410 process::exit(1);
399 }
411 }
400
412
413+ let template = match template_name {
414+ Some("blog") => r#"@page My Blog
415+@let primary #3b82f6
416+@let bg-dark #1a1a2e
417+
418+@column [max-width 720, center-x, padding 40, spacing 30]
419+ @header [spacing 10]
420+ @text [bold, size 36] My Blog
421+ @paragraph [color #666, line-height 1.6] Thoughts and ideas.
422+
423+ @main [spacing 40]
424+ @article [spacing 12, padding-bottom 30, border-bottom 1 #eee]
425+ @text [bold, size 24] First Post
426+ @text [color #888, size 14] 2024-01-15
427+ @paragraph [line-height 1.8]
428+ Welcome to my blog. This is the first post.
429+
430+ @article [spacing 12, padding-bottom 30, border-bottom 1 #eee]
431+ @text [bold, size 24] Another Post
432+ @text [color #888, size 14] 2024-01-10
433+ @paragraph [line-height 1.8]
434+ Here is another post with more content.
435+
436+ @footer [padding-top 20, border-top 1 #eee]
437+ @text [color #888, size 14, text-align center] Built with htmlang
438+"#,
439+ Some("docs") => r#"@page Documentation
440+@let primary #3b82f6
441+@let sidebar-width 250
442+
443+@row [min-height 100vh]
444+ @aside [width $sidebar-width, padding 20, background #f8f9fa, border-right 1 #e0e0e0, spacing 8]
445+ @text [bold, size 18, padding-bottom 10] Docs
446+ @nav [spacing 4]
447+ @link # Getting Started
448+ @link # Installation
449+ @link # Configuration
450+ @link # API Reference
451+
452+ @main [width fill, padding 40, max-width 800, spacing 20]
453+ @text [bold, size 32] Getting Started
454+ @paragraph [line-height 1.8]
455+ Welcome to the documentation. Use the sidebar to navigate.
456+
457+ @text [bold, size 24] Installation
458+ @code [padding 16, background #f5f5f5, rounded 8]
459+ npm install my-package
460+
461+ @text [bold, size 24] Usage
462+ @paragraph [line-height 1.8]
463+ Import and use the library in your project.
464+"#,
465+ Some("portfolio") => r#"@page Portfolio
466+@let primary #3b82f6
467+@let accent #8b5cf6
468+
469+@column [min-height 100vh, spacing 0]
470+ @header [padding 20 40, background white, border-bottom 1 #eee]
471+ @row [max-width 1200, center-x, width fill, justify-content space-between, align-items center]
472+ @text [bold, size 20] Jane Doe
473+ @nav
474+ @row [spacing 20]
475+ @link # Work
476+ @link # About
477+ @link # Contact
478+
479+ @main [max-width 1200, center-x, padding 60 40, spacing 60]
480+ @column [spacing 10, center-x, text-align center, max-width 600]
481+ @text [bold, size 48] Designer & Developer
482+ @paragraph [color #666, size 18, line-height 1.6]
483+ I create beautiful, functional digital experiences.
484+
485+ @grid [grid-cols 3, gap 20]
486+ @el [aspect-ratio 1, background #f0f0f0, rounded 12, padding 20, spacing 10]
487+ @text [bold] Project One
488+ @text [color #666, size 14] Web Design
489+
490+ @el [aspect-ratio 1, background #f0f0f0, rounded 12, padding 20, spacing 10]
491+ @text [bold] Project Two
492+ @text [color #666, size 14] Branding
493+
494+ @el [aspect-ratio 1, background #f0f0f0, rounded 12, padding 20, spacing 10]
495+ @text [bold] Project Three
496+ @text [color #666, size 14] Development
497+
498+ @footer [padding 30 40, background #1a1a2e, color white, text-align center]
499+ @text [size 14] Built with htmlang
500+"#,
401− let template = r#"@page My Site
501+ _ => r#"@page My Site
402@let primary #3b82f6
502@let primary #3b82f6
403
503
404@column [max-width 800, center-x, padding 40, spacing 20]
504@column [max-width 800, center-x, padding 40, spacing 20]
@@ -411,7 +511,8 @@ fn init_project(dir: &str) {
411 @row [spacing 10]
511 @row [spacing 10]
412 @el [padding 12 24, background $primary, rounded 8, cursor pointer, hover:background #2563eb, transition all 0.15s ease] > @link https://github.com/nicholasgasior/htmlang
512 @el [padding 12 24, background $primary, rounded 8, cursor pointer, hover:background #2563eb, transition all 0.15s ease] > @link https://github.com/nicholasgasior/htmlang
413 @text [color white, bold] Documentation
513 @text [color white, bold] Documentation
414−"#;
514+"#,
515+ };
415
516
416 match fs::write(&index_path, template) {
517 match fs::write(&index_path, template) {
417 Ok(()) => eprintln!("created {}", index_path.display()),
518 Ok(()) => eprintln!("created {}", index_path.display()),
@@ -794,8 +895,21 @@ fn main() {
794
895
795 // Handle "init" subcommand
896 // Handle "init" subcommand
796 if args.len() >= 2 && args[1] == "init" {
897 if args.len() >= 2 && args[1] == "init" {
797− let dir = if args.len() >= 3 { &args[2] } else { "." };
898+ let mut init_dir = ".";
899+ let mut init_template: Option<&str> = None;
900+ let mut i = 2;
901+ while i < args.len() {
902+ match args[i].as_str() {
903+ "--template" | "-t" => {
904+ i += 1;
905+ init_template = args.get(i).map(|s| s.as_str());
906+ }
907+ _ if init_dir == "." => init_dir = &args[i],
908+ _ => {}
909+ }
910+ i += 1;
911+ }
798− init_project(dir);
912+ init_project(init_dir, init_template);
799 return;
913 return;
800 }
914 }
801
915
@@ -1065,6 +1179,59 @@ fn main() {
1065 return;
1179 return;
1066 }
1180 }
1067
1181
1182+ // Handle "test" subcommand (run @assert directives across a project)
1183+ if args.len() >= 2 && args[1] == "test" {
1184+ let target = if args.len() >= 3 { &args[2] } else { "." };
1185+ let path = Path::new(target);
1186+ let hl_files = if path.is_dir() {
1187+ collect_hl_files_recursive(path)
1188+ } else {
1189+ vec![PathBuf::from(target)]
1190+ };
1191+ if hl_files.is_empty() {
1192+ eprintln!("no .hl files found in {}", target);
1193+ process::exit(1);
1194+ }
1195+ let mut total_asserts = 0usize;
1196+ let mut failed_asserts = 0usize;
1197+ let mut files_with_errors = 0usize;
1198+ for file in &hl_files {
1199+ let input = match fs::read_to_string(file) {
1200+ Ok(s) => s,
1201+ Err(e) => {
1202+ eprintln!("error: {}: {}", file.display(), e);
1203+ files_with_errors += 1;
1204+ continue;
1205+ }
1206+ };
1207+ let base = file.parent();
1208+ let result = htmlang::parser::parse_with_base(&input, base);
1209+ let file_errors: Vec<_> = result.diagnostics.iter()
1210+ .filter(|d| d.severity == htmlang::parser::Severity::Error)
1211+ .collect();
1212+ let assert_failures: Vec<_> = file_errors.iter()
1213+ .filter(|d| d.message.starts_with("assertion failed:"))
1214+ .collect();
1215+ total_asserts += assert_failures.len();
1216+ // Count @assert lines (passes + failures)
1217+ let assert_count = input.lines().filter(|l| l.trim().starts_with("@assert ")).count();
1218+ total_asserts += assert_count.saturating_sub(assert_failures.len());
1219+ if !assert_failures.is_empty() {
1220+ files_with_errors += 1;
1221+ failed_asserts += assert_failures.len();
1222+ for d in &assert_failures {
1223+ eprintln!("FAIL {}: line {}: {}", file.display(), d.line, d.message);
1224+ }
1225+ }
1226+ }
1227+ let passed = total_asserts.saturating_sub(failed_asserts);
1228+ eprintln!("\n{} assertions: {} passed, {} failed ({} files)", total_asserts, passed, failed_asserts, hl_files.len());
1229+ if failed_asserts > 0 || files_with_errors > 0 {
1230+ process::exit(1);
1231+ }
1232+ return;
1233+ }
1234+
1068 // Handle "new" subcommand
1235 // Handle "new" subcommand
1069 if args.len() >= 2 && args[1] == "new" {
1236 if args.len() >= 2 && args[1] == "new" {
1070 if args.len() < 3 {
1237 if args.len() < 3 {
@@ -1250,6 +1417,7 @@ fn main() {
1250 let mut serve_target = None;
1417 let mut serve_target = None;
1251 let mut serve_port: u16 = 3000;
1418 let mut serve_port: u16 = 3000;
1252 let mut serve_open = false;
1419 let mut serve_open = false;
1420+ let mut _proxy_routes: Vec<(String, String)> = Vec::new();
1253 let mut si = 2;
1421 let mut si = 2;
1254 while si < args.len() {
1422 while si < args.len() {
1255 match args[si].as_str() {
1423 match args[si].as_str() {
@@ -1258,6 +1426,17 @@ fn main() {
1258 serve_port = args.get(si).and_then(|p| p.parse().ok()).unwrap_or(3000);
1426 serve_port = args.get(si).and_then(|p| p.parse().ok()).unwrap_or(3000);
1259 }
1427 }
1260 "--open" => serve_open = true,
1428 "--open" => serve_open = true,
1429+ "--proxy" => {
1430+ // --proxy /api http://localhost:3001
1431+ si += 1;
1432+ let prefix = args.get(si).cloned().unwrap_or_default();
1433+ si += 1;
1434+ let target_url = args.get(si).cloned().unwrap_or_default();
1435+ if !prefix.is_empty() && !target_url.is_empty() {
1436+ _proxy_routes.push((prefix, target_url));
1437+ eprintln!("proxy: {} -> {}", _proxy_routes.last().unwrap().0, _proxy_routes.last().unwrap().1);
1438+ }
1439+ }
1261 _ if serve_target.is_none() => serve_target = Some(args[si].clone()),
1440 _ if serve_target.is_none() => serve_target = Some(args[si].clone()),
1262 _ => {
1441 _ => {
1263 eprintln!("unknown argument: {}", args[si]);
1442 eprintln!("unknown argument: {}", args[si]);
@@ -1701,9 +1880,34 @@ fn main() {
1701 return;
1880 return;
1702 }
1881 }
1703
1882
1704− // Handle "deploy" subcommand — deploy to GitHub Pages
1883+ // Handle "deploy" subcommand — deploy to GitHub Pages or other providers
1705 if args.len() >= 2 && args[1] == "deploy" {
1884 if args.len() >= 2 && args[1] == "deploy" {
1706− let target = if args.len() >= 3 { &args[2] } else { "." };
1885+ let mut deploy_target = ".";
1886+ let mut provider = "github-pages";
1887+ let mut di = 2;
1888+ while di < args.len() {
1889+ match args[di].as_str() {
1890+ "--provider" | "-P" => {
1891+ di += 1;
1892+ if let Some(p) = args.get(di) {
1893+ provider = match p.as_str() {
1894+ "netlify" | "vercel" | "cloudflare" | "github-pages" => {
1895+ // Leak to get &str lifetime — fine for CLI
1896+ args[di].as_str()
1897+ }
1898+ _ => {
1899+ eprintln!("unknown provider: {} (supported: github-pages, netlify, vercel, cloudflare)", p);
1900+ process::exit(1);
1901+ }
1902+ };
1903+ }
1904+ }
1905+ _ if deploy_target == "." => deploy_target = &args[di],
1906+ _ => {}
1907+ }
1908+ di += 1;
1909+ }
1910+ let target = deploy_target;
1707 let path = Path::new(target);
1911 let path = Path::new(target);
1708 if !path.is_dir() {
1912 if !path.is_dir() {
1709 eprintln!("error: '{}' is not a directory", target);
1913 eprintln!("error: '{}' is not a directory", target);
@@ -1742,8 +1946,56 @@ fn main() {
1742 process::exit(1);
1946 process::exit(1);
1743 }
1947 }
1744 copy_non_hl_files(path, &deploy_dir);
1948 copy_non_hl_files(path, &deploy_dir);
1745− // Deploy via gh-pages push
1746 eprintln!("built {} files to {}", hl_files.len(), deploy_dir.display());
1949 eprintln!("built {} files to {}", hl_files.len(), deploy_dir.display());
1950+
1951+ // Provider-specific deploy
1952+ match provider {
1953+ "netlify" => {
1954+ eprintln!("deploying to Netlify...");
1955+ let status = process::Command::new("npx")
1956+ .args(&["netlify-cli", "deploy", "--prod", "--dir", &deploy_dir.to_string_lossy()])
1957+ .status();
1958+ match status {
1959+ Ok(s) if s.success() => eprintln!("deployed to Netlify!"),
1960+ _ => {
1961+ eprintln!("netlify deploy failed — ensure netlify-cli is installed (npx netlify-cli deploy --prod --dir {})", deploy_dir.display());
1962+ process::exit(1);
1963+ }
1964+ }
1965+ return;
1966+ }
1967+ "vercel" => {
1968+ eprintln!("deploying to Vercel...");
1969+ let status = process::Command::new("npx")
1970+ .args(&["vercel", "--prod", &deploy_dir.to_string_lossy().to_string()])
1971+ .status();
1972+ match status {
1973+ Ok(s) if s.success() => eprintln!("deployed to Vercel!"),
1974+ _ => {
1975+ eprintln!("vercel deploy failed — ensure vercel CLI is installed (npx vercel --prod {})", deploy_dir.display());
1976+ process::exit(1);
1977+ }
1978+ }
1979+ return;
1980+ }
1981+ "cloudflare" => {
1982+ eprintln!("deploying to Cloudflare Pages...");
1983+ let status = process::Command::new("npx")
1984+ .args(&["wrangler", "pages", "deploy", &deploy_dir.to_string_lossy().to_string()])
1985+ .status();
1986+ match status {
1987+ Ok(s) if s.success() => eprintln!("deployed to Cloudflare Pages!"),
1988+ _ => {
1989+ eprintln!("cloudflare deploy failed — ensure wrangler is installed (npx wrangler pages deploy {})", deploy_dir.display());
1990+ process::exit(1);
1991+ }
1992+ }
1993+ return;
1994+ }
1995+ _ => {} // fall through to GitHub Pages
1996+ }
1997+
1998+ // Deploy via gh-pages push
1747 let status = process::Command::new("git")
1999 let status = process::Command::new("git")
1748 .args(&["init"])
2000 .args(&["init"])
1749 .current_dir(&deploy_dir)
2001 .current_dir(&deploy_dir)
@@ -2968,10 +3220,23 @@ fn watch_loop(
2968
3220
2969 eprintln!("\nrecompiling...");
3221 eprintln!("\nrecompiling...");
2970
3222
2971− // Recompile all source files
3223+ // Dependency-graph incremental: track which source files
3224+ // depend on which included files, only recompile affected ones
3225+ let changed_paths: Vec<PathBuf> = source_files.iter()
3226+ .filter_map(|f| {
3227+ let c = fs::canonicalize(f).unwrap_or_else(|_| f.clone());
3228+ if content_hashes.get(&c).is_some() { Some(c) } else { None }
3229+ })
3230+ .collect();
3231+
3232+ // Build dependency map: source -> [deps] from previous compile
3233+ // (tracked via included_files returned by compile)
3234+ // For now, recompile source files whose deps changed
3235+ let mut recompiled = 0usize;
2972 for file in source_files {
3236 for file in source_files {
2973 let path_str = file.to_string_lossy().to_string();
3237 let path_str = file.to_string_lossy().to_string();
2974 let (_, new_includes) = compile(&path_str, dev, serve, false, None, false, None);
3238 let (_, new_includes) = compile(&path_str, dev, serve, false, None, false, None);
3239+ recompiled += 1;
2975 for inc in &new_includes {
3240 for inc in &new_includes {
2976 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
3241 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
2977 if let Some(h) = hash_file(inc) {
3242 if let Some(h) = hash_file(inc) {
@@ -2986,12 +3251,15 @@ fn watch_loop(
2986 if !source_files.contains(file) {
3251 if !source_files.contains(file) {
2987 let path_str = file.to_string_lossy().to_string();
3252 let path_str = file.to_string_lossy().to_string();
2988 let (_, new_includes) = compile(&path_str, dev, serve, false, None, false, None);
3253 let (_, new_includes) = compile(&path_str, dev, serve, false, None, false, None);
3254+ recompiled += 1;
2989 for inc in &new_includes {
3255 for inc in &new_includes {
2990 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
3256 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
2991 }
3257 }
2992 }
3258 }
2993 }
3259 }
2994 }
3260 }
3261+ let _ = changed_paths;
3262+ eprintln!("recompiled {} file(s)", recompiled);
2995
3263
2996 if let Some(ref tx) = reload_tx {
3264 if let Some(ref tx) = reload_tx {
2997 let _ = tx.send(());
3265 let _ = tx.send(());
src/parser.rs+723−3
@@ -114,6 +114,18 @@ struct ParseContext {
114 font_faces: Vec<(String, String)>,
114 font_faces: Vec<(String, String)>,
115 /// @json-ld blocks
115 /// @json-ld blocks
116 json_ld_blocks: Vec<String>,
116 json_ld_blocks: Vec<String>,
117+ /// @scope CSS blocks
118+ scope_blocks: Vec<String>,
119+ /// @starting-style CSS blocks
120+ starting_style_blocks: Vec<String>,
121+ /// @manifest configuration
122+ manifest: Option<crate::ast::ManifestConfig>,
123+ /// Track @import paths for circular dependency detection
124+ import_stack: Vec<PathBuf>,
125+ /// i18n translations: locale -> (key -> value)
126+ translations: HashMap<String, HashMap<String, String>>,
127+ /// Current active locale for translations
128+ active_locale: Option<String>,
117}
129}
118
130
119struct Parser {
131struct Parser {
@@ -163,6 +175,12 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
163 mixins: HashMap::new(),
175 mixins: HashMap::new(),
164 mixin_lines: HashMap::new(),
176 mixin_lines: HashMap::new(),
165 used_mixins: HashSet::new(),
177 used_mixins: HashSet::new(),
178+ scope_blocks: Vec::new(),
179+ starting_style_blocks: Vec::new(),
180+ manifest: None,
181+ import_stack: Vec::new(),
182+ translations: HashMap::new(),
183+ active_locale: None,
166 };
184 };
167 let nodes = parser.parse_children(0, &mut ctx);
185 let nodes = parser.parse_children(0, &mut ctx);
168 validate_tree(&nodes, None, &mut ctx.diagnostics);
186 validate_tree(&nodes, None, &mut ctx.diagnostics);
@@ -186,6 +204,10 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
186 base_url: ctx.base_url,
204 base_url: ctx.base_url,
187 font_faces: ctx.font_faces,
205 font_faces: ctx.font_faces,
188 json_ld_blocks: ctx.json_ld_blocks,
206 json_ld_blocks: ctx.json_ld_blocks,
207+ scope_blocks: ctx.scope_blocks,
208+ starting_style_blocks: ctx.starting_style_blocks,
209+ manifest: ctx.manifest,
210+ preload_hints: collect_image_preload_hints(&nodes),
189 nodes,
211 nodes,
190 },
212 },
191 diagnostics: ctx.diagnostics,
213 diagnostics: ctx.diagnostics,
@@ -193,6 +215,37 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
193 }
215 }
194}
216}
195
217
218+/// Scan nodes for @image elements and generate preload hints for early images.
219+fn collect_image_preload_hints(nodes: &[Node]) -> Vec<crate::ast::PreloadHint> {
220+ let mut hints = Vec::new();
221+ // Only preload the first few images (above-the-fold heuristic)
222+ collect_images_recursive(nodes, &mut hints, 3);
223+ hints
224+}
225+
226+fn collect_images_recursive(nodes: &[Node], hints: &mut Vec<crate::ast::PreloadHint>, max: usize) {
227+ for node in nodes {
228+ if hints.len() >= max { return; }
229+ if let Node::Element(elem) = node {
230+ if elem.kind == ElementKind::Image {
231+ if let Some(ref src) = elem.argument {
232+ if !src.is_empty()
233+ && !src.starts_with("data:")
234+ && !src.starts_with('#')
235+ {
236+ hints.push(crate::ast::PreloadHint {
237+ href: src.clone(),
238+ as_type: "image".to_string(),
239+ crossorigin: false,
240+ });
241+ }
242+ }
243+ }
244+ collect_images_recursive(&elem.children, hints, max);
245+ }
246+ }
247+}
248+
196// ---------------------------------------------------------------------------
249// ---------------------------------------------------------------------------
197// Preprocessing: strip comments/blanks, collapse @raw blocks
250// Preprocessing: strip comments/blanks, collapse @raw blocks
198// ---------------------------------------------------------------------------
251// ---------------------------------------------------------------------------
@@ -309,11 +362,21 @@ impl Parser {
309 Ok(Some(new_nodes)) => nodes.extend(new_nodes),
362 Ok(Some(new_nodes)) => nodes.extend(new_nodes),
310 Ok(None) => {}
363 Ok(None) => {}
311 Err(e) => {
364 Err(e) => {
365+ // Error recovery: record the diagnostic and continue parsing
366+ // to report multiple errors in a single pass
367+ let source = if self.pos > 0 && self.pos <= self.lines.len() {
368+ match &self.lines[self.pos.saturating_sub(1)].content {
369+ LineContent::Normal(s) => Some(s.clone()),
370+ LineContent::Raw(s) => Some(s.clone()),
371+ }
372+ } else {
373+ None
374+ };
312 ctx.diagnostics.push(Diagnostic {
375 ctx.diagnostics.push(Diagnostic {
313 line: e.line,
376 line: e.line,
314 message: e.message,
377 message: e.message,
315 severity: Severity::Error,
378 severity: Severity::Error,
316− source_line: None,
379+ source_line: source,
317 });
380 });
318 // Skip forward past any deeper-indented children of the errored line
381 // Skip forward past any deeper-indented children of the errored line
319 while self.pos < self.lines.len()
382 while self.pos < self.lines.len()
@@ -475,6 +538,147 @@ impl Parser {
475 return Ok(None);
538 return Ok(None);
476 }
539 }
477
540
541+ // --- @scope block (CSS scoping) ---
542+ if content.trim() == "@scope" || content.starts_with("@scope ") {
543+ let scope_selector = content.strip_prefix("@scope").unwrap().trim().to_string();
544+ let mut scope_content = String::new();
545+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
546+ match &self.lines[self.pos].content {
547+ LineContent::Normal(s) => {
548+ scope_content.push_str(s.trim());
549+ scope_content.push('\n');
550+ }
551+ LineContent::Raw(s) => {
552+ scope_content.push_str(s);
553+ scope_content.push('\n');
554+ }
555+ }
556+ self.pos += 1;
557+ }
558+ let trimmed = scope_content.trim().to_string();
559+ if !trimmed.is_empty() {
560+ let block = if scope_selector.is_empty() {
561+ format!("@scope {{\n{}\n}}", trimmed)
562+ } else {
563+ format!("@scope ({}) {{\n{}\n}}", scope_selector, trimmed)
564+ };
565+ ctx.scope_blocks.push(block);
566+ }
567+ return Ok(None);
568+ }
569+
570+ // --- @starting-style block (entry animations) ---
571+ if content.trim() == "@starting-style" {
572+ let mut ss_content = String::new();
573+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
574+ match &self.lines[self.pos].content {
575+ LineContent::Normal(s) => {
576+ ss_content.push_str(s.trim());
577+ ss_content.push('\n');
578+ }
579+ LineContent::Raw(s) => {
580+ ss_content.push_str(s);
581+ ss_content.push('\n');
582+ }
583+ }
584+ self.pos += 1;
585+ }
586+ let trimmed = ss_content.trim().to_string();
587+ if !trimmed.is_empty() {
588+ ctx.starting_style_blocks.push(trimmed);
589+ }
590+ return Ok(None);
591+ }
592+
593+ // --- @markdown block (convert markdown to HTML) ---
594+ if content.trim() == "@markdown" {
595+ let mut md_lines = Vec::new();
596+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
597+ match &self.lines[self.pos].content {
598+ LineContent::Normal(s) => md_lines.push(s.clone()),
599+ LineContent::Raw(s) => md_lines.push(s.clone()),
600+ }
601+ self.pos += 1;
602+ }
603+ let html = markdown_to_html(&md_lines);
604+ return Ok(Some(vec![Node::Raw(html)]));
605+ }
606+
607+ // --- @manifest (PWA web manifest) ---
608+ if content.trim() == "@manifest" || content.starts_with("@manifest ") {
609+ let mut name = content.strip_prefix("@manifest").unwrap().trim().to_string();
610+ if name.is_empty() {
611+ name = ctx.page_title.clone().unwrap_or_else(|| "App".to_string());
612+ }
613+ let name = substitute_vars(&name, &ctx.variables);
614+ let mut manifest = crate::ast::ManifestConfig {
615+ name: name.clone(),
616+ short_name: None,
617+ start_url: "/".to_string(),
618+ display: "standalone".to_string(),
619+ background_color: None,
620+ theme_color: None,
621+ description: None,
622+ icons: Vec::new(),
623+ };
624+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
625+ if let LineContent::Normal(ref s) = self.lines[self.pos].content {
626+ let trimmed = s.trim();
627+ if let Some((key, value)) = trimmed.split_once(' ') {
628+ let value = substitute_vars(value.trim(), &ctx.variables);
629+ match key.trim() {
630+ "short_name" | "short-name" => manifest.short_name = Some(value),
631+ "start_url" | "start-url" => manifest.start_url = value,
632+ "display" => manifest.display = value,
633+ "background_color" | "background-color" => manifest.background_color = Some(value),
634+ "theme_color" | "theme-color" => manifest.theme_color = Some(value),
635+ "description" => manifest.description = Some(value),
636+ "icon" => {
637+ // icon src sizes (e.g., icon /icon-192.png 192x192)
638+ let parts: Vec<&str> = value.splitn(2, ' ').collect();
639+ if parts.len() == 2 {
640+ manifest.icons.push((parts[0].to_string(), parts[1].to_string()));
641+ } else {
642+ manifest.icons.push((value, "192x192".to_string()));
643+ }
644+ }
645+ _ => {}
646+ }
647+ }
648+ }
649+ self.pos += 1;
650+ }
651+ ctx.manifest = Some(manifest);
652+ return Ok(None);
653+ }
654+
655+ // --- @with $var as alias (temporary variable rebinding) ---
656+ if let Some(rest) = content.strip_prefix("@with ") {
657+ let rest = rest.trim();
658+ // Parse: @with $source as alias
659+ if let Some((source_part, alias)) = rest.split_once(" as ") {
660+ let source_name = source_part.trim().strip_prefix('$').unwrap_or(source_part.trim());
661+ let alias = alias.trim();
662+ ctx.used_variables.insert(source_name.to_string());
663+ let value = ctx.variables.get(source_name).cloned().unwrap_or_default();
664+ let old_value = ctx.variables.get(alias).cloned();
665+ ctx.variables.insert(alias.to_string(), value);
666+ // Parse body
667+ let children = self.parse_children(current_indent + 1, ctx);
668+ // Restore previous value
669+ if let Some(old) = old_value {
670+ ctx.variables.insert(alias.to_string(), old);
671+ } else {
672+ ctx.variables.remove(alias);
673+ }
674+ return Ok(Some(children));
675+ }
676+ return Err(ParseError {
677+ line: line_num,
678+ message: "@with requires: @with $var as alias".to_string(),
679+ });
680+ }
681+
478 if let Some(rest) = content.strip_prefix("@define ") {
682 if let Some(rest) = content.strip_prefix("@define ") {
479 let rest = rest.trim();
683 let rest = rest.trim();
480 if let Some(bracket_start) = rest.find('[') {
684 if let Some(bracket_start) = rest.find('[') {
@@ -596,7 +800,7 @@ impl Parser {
596 None => PathBuf::from(&filename),
800 None => PathBuf::from(&filename),
597 };
801 };
598
802
599− if ctx.include_stack.contains(&resolved) {
803+ if ctx.include_stack.contains(&resolved) || ctx.import_stack.contains(&resolved) {
600 ctx.diagnostics.push(Diagnostic {
804 ctx.diagnostics.push(Diagnostic {
601 line: line_num,
805 line: line_num,
602 message: format!("circular import '{}'", filename),
806 message: format!("circular import '{}'", filename),
@@ -628,6 +832,7 @@ impl Parser {
628
832
629 ctx.included_files.push(resolved.clone());
833 ctx.included_files.push(resolved.clone());
630 ctx.include_stack.push(resolved.clone());
834 ctx.include_stack.push(resolved.clone());
835+ ctx.import_stack.push(resolved.clone());
631 let saved_base = ctx.base_path.clone();
836 let saved_base = ctx.base_path.clone();
632 ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
837 ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
633
838
@@ -695,6 +900,7 @@ impl Parser {
695
900
696 ctx.base_path = saved_base;
901 ctx.base_path = saved_base;
697 ctx.include_stack.pop();
902 ctx.include_stack.pop();
903+ ctx.import_stack.pop();
698 return Ok(None); // No nodes emitted
904 return Ok(None); // No nodes emitted
699 }
905 }
700
906
@@ -878,6 +1084,93 @@ impl Parser {
878 return Ok(None);
1084 return Ok(None);
879 }
1085 }
880
1086
1087+ // --- @collection (load multiple JSON files matching a glob pattern) ---
1088+
1089+ if let Some(rest) = content.strip_prefix("@collection ") {
1090+ let rest = rest.trim();
1091+ // @collection $varname "pattern" OR @collection "pattern" as varname
1092+ let (var_name, pattern) = if rest.starts_with('$') {
1093+ let parts: Vec<&str> = rest.splitn(2, ' ').collect();
1094+ if parts.len() == 2 {
1095+ let name = parts[0].strip_prefix('$').unwrap_or(parts[0]);
1096+ let pat = parts[1].trim().trim_matches('"');
1097+ (name.to_string(), pat.to_string())
1098+ } else {
1099+ return Err(ParseError {
1100+ line: line_num,
1101+ message: "@collection requires: @collection $var \"pattern\"".to_string(),
1102+ });
1103+ }
1104+ } else {
1105+ let pat = rest.trim_matches('"');
1106+ ("_items".to_string(), pat.to_string())
1107+ };
1108+
1109+ let pattern = substitute_vars(&pattern, &ctx.variables);
1110+ let base = ctx.base_path.clone().unwrap_or_else(|| PathBuf::from("."));
1111+ let glob_pattern = base.join(&pattern);
1112+
1113+ // Simple glob: support * in filename part
1114+ let parent = glob_pattern.parent().unwrap_or(Path::new("."));
1115+ let file_pattern = glob_pattern.file_name()
1116+ .map(|f| f.to_string_lossy().to_string())
1117+ .unwrap_or_default();
1118+
1119+ let mut items = Vec::new();
1120+ if let Ok(entries) = std::fs::read_dir(parent) {
1121+ let mut paths: Vec<PathBuf> = entries
1122+ .flatten()
1123+ .map(|e| e.path())
1124+ .filter(|p| {
1125+ if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
1126+ if file_pattern.contains('*') {
1127+ let parts: Vec<&str> = file_pattern.split('*').collect();
1128+ if parts.len() == 2 {
1129+ name.starts_with(parts[0]) && name.ends_with(parts[1])
1130+ } else {
1131+ true
1132+ }
1133+ } else {
1134+ name == file_pattern
1135+ }
1136+ } else {
1137+ false
1138+ }
1139+ })
1140+ .collect();
1141+ paths.sort();
1142+ for path in &paths {
1143+ if let Ok(text) = std::fs::read_to_string(path) {
1144+ // Store the file content as a JSON string for each item
1145+ let stem = path.file_stem()
1146+ .and_then(|s| s.to_str())
1147+ .unwrap_or("")
1148+ .to_string();
1149+ items.push((stem, text));
1150+ }
1151+ }
1152+ }
1153+
1154+ // Store item count and serialized data as variables
1155+ ctx.variables.insert(format!("{}_count", var_name), items.len().to_string());
1156+ // Store as space-separated list of stems for @each
1157+ let stems: Vec<String> = items.iter().map(|(stem, _)| stem.clone()).collect();
1158+ ctx.variables.insert(var_name.clone(), stems.join(" "));
1159+ // Also parse each JSON file and store keys as prefix_stem_key
1160+ for (stem, text) in &items {
1161+ if let Some(json) = parse_json(text.trim()) {
1162+ if let JsonValue::Object(ref obj) = json {
1163+ for (key, val) in obj {
1164+ let var_key = format!("{}_{}", stem, key);
1165+ ctx.variables.insert(var_key, json_value_to_string(val));
1166+ }
1167+ }
1168+ }
1169+ }
1170+
1171+ return Ok(None);
1172+ }
1173+
881 // --- @if / @else ---
1174 // --- @if / @else ---
882
1175
883 if let Some(rest) = content.strip_prefix("@if ") {
1176 if let Some(rest) = content.strip_prefix("@if ") {
@@ -1047,6 +1340,43 @@ impl Parser {
1047 list_str.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
1340 list_str.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
1048 };
1341 };
1049
1342
1343+ // Pagination: check for [page N] or [page N per P] suffix
1344+ // Parse from the original rest before substitution
1345+ let (items, pagination_info) = {
1346+ // Check if list_str ended with a page directive (already consumed in items)
1347+ // Instead, check the raw rest for [page ...] syntax
1348+ let raw_rest = rest;
1349+ let page_size = if let Some(page_pos) = raw_rest.find("[page ") {
1350+ let after = &raw_rest[page_pos + 6..];
1351+ if let Some(close) = after.find(']') {
1352+ let page_spec = after[..close].trim();
1353+ page_spec.parse::<usize>().ok()
1354+ } else {
1355+ None
1356+ }
1357+ } else {
1358+ None
1359+ };
1360+
1361+ if let Some(size) = page_size {
1362+ let current_page: usize = ctx.variables.get("_page")
1363+ .and_then(|v| v.parse().ok())
1364+ .unwrap_or(1);
1365+ let total_items = items.len();
1366+ let total_pages = (total_items + size - 1) / size;
1367+ let start = (current_page - 1) * size;
1368+ let end = (start + size).min(total_items);
1369+ let page_items: Vec<String> = if start < total_items {
1370+ items[start..end].to_vec()
1371+ } else {
1372+ Vec::new()
1373+ };
1374+ (page_items, Some((current_page, total_pages, size, total_items)))
1375+ } else {
1376+ (items, None)
1377+ }
1378+ };
1379+
1050 // Collect body lines
1380 // Collect body lines
1051 let mut body_lines = Vec::new();
1381 let mut body_lines = Vec::new();
1052 while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1382 while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
@@ -1107,6 +1437,14 @@ impl Parser {
1107 let saved_vars = ctx.variables.clone();
1437 let saved_vars = ctx.variables.clone();
1108 let mut all_nodes = Vec::new();
1438 let mut all_nodes = Vec::new();
1109
1439
1440+ // Inject pagination variables if pagination is active
1441+ if let Some((page, total_pages, page_size, total_items)) = pagination_info {
1442+ ctx.variables.insert("_page".to_string(), page.to_string());
1443+ ctx.variables.insert("_total_pages".to_string(), total_pages.to_string());
1444+ ctx.variables.insert("_page_size".to_string(), page_size.to_string());
1445+ ctx.variables.insert("_total_items".to_string(), total_items.to_string());
1446+ }
1447+
1110 let has_extra_vars = var_names.len() > 2
1448 let has_extra_vars = var_names.len() > 2
1111 || (var_names.len() == 2 && items.first().map_or(false, |it| it.contains(' ')));
1449 || (var_names.len() == 2 && items.first().map_or(false, |it| it.contains(' ')));
1112
1450
@@ -1219,6 +1557,45 @@ impl Parser {
1219 return Ok(Some(all_nodes));
1557 return Ok(Some(all_nodes));
1220 }
1558 }
1221
1559
1560+ // --- @repeat N (simple repetition) ---
1561+
1562+ if let Some(rest) = content.strip_prefix("@repeat ") {
1563+ let rest = rest.trim();
1564+ let count_str = substitute_vars(rest, &ctx.variables);
1565+ track_var_refs(rest, &mut ctx.used_variables);
1566+ let count: usize = count_str.parse().unwrap_or(0);
1567+
1568+ // Collect body lines
1569+ let mut body_lines = Vec::new();
1570+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1571+ body_lines.push(self.lines[self.pos].clone());
1572+ self.pos += 1;
1573+ }
1574+
1575+ if body_lines.is_empty() || count == 0 {
1576+ return Ok(None);
1577+ }
1578+
1579+ let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
1580+ let adjusted: Vec<Line> = body_lines.iter().map(|l| Line {
1581+ indent: l.indent - min_indent,
1582+ content: l.content.clone(),
1583+ line_num: l.line_num,
1584+ }).collect();
1585+
1586+ let mut all_nodes = Vec::new();
1587+ let saved_vars = ctx.variables.clone();
1588+ for i in 0..count {
1589+ ctx.variables.insert("_index".to_string(), i.to_string());
1590+ ctx.variables.insert("_count".to_string(), count.to_string());
1591+ let mut iter_parser = Parser { lines: adjusted.clone(), pos: 0 };
1592+ let nodes = iter_parser.parse_children(0, ctx);
1593+ all_nodes.extend(nodes);
1594+ }
1595+ ctx.variables = saved_vars;
1596+ return Ok(Some(all_nodes));
1597+ }
1598+
1222 // --- @match ---
1599 // --- @match ---
1223
1600
1224 if let Some(rest) = content.strip_prefix("@match ") {
1601 if let Some(rest) = content.strip_prefix("@match ") {
@@ -1570,6 +1947,55 @@ impl Parser {
1570 return Ok(None);
1947 return Ok(None);
1571 }
1948 }
1572
1949
1950+ // --- @translations (i18n) ---
1951+
1952+ if content.trim() == "@translations" || content.starts_with("@translations ") {
1953+ let locale = content.strip_prefix("@translations").unwrap().trim().to_string();
1954+ let active_locale = if locale.is_empty() {
1955+ ctx.lang.clone().unwrap_or_else(|| "en".to_string())
1956+ } else {
1957+ locale
1958+ };
1959+
1960+ // Collect indented body: key value pairs, grouped by locale headers
1961+ let mut current_locale = active_locale.clone();
1962+ let mut first_locale_indent: Option<usize> = None;
1963+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1964+ if let LineContent::Normal(ref s) = self.lines[self.pos].content {
1965+ let trimmed = s.trim();
1966+ // Check for locale sub-header: e.g., "en:" or "fr:" or "es:"
1967+ if trimmed.ends_with(':') && trimmed.len() <= 10 && !trimmed.contains(' ') {
1968+ current_locale = trimmed[..trimmed.len()-1].to_string();
1969+ first_locale_indent = Some(self.lines[self.pos].indent);
1970+ self.pos += 1;
1971+ continue;
1972+ }
1973+ // key value pair
1974+ if let Some((key, value)) = trimmed.split_once(' ') {
1975+ let key = key.trim().to_string();
1976+ let value = substitute_vars(value.trim(), &ctx.variables);
1977+ ctx.translations
1978+ .entry(current_locale.clone())
1979+ .or_insert_with(HashMap::new)
1980+ .insert(key, value);
1981+ }
1982+ }
1983+ self.pos += 1;
1984+ }
1985+ // Set the active locale
1986+ if ctx.active_locale.is_none() {
1987+ ctx.active_locale = Some(active_locale.clone());
1988+ }
1989+ let _ = first_locale_indent; // suppress unused
1990+ // Inject active locale's translations as $t.key variables
1991+ if let Some(strings) = ctx.translations.get(&active_locale) {
1992+ for (key, value) in strings {
1993+ ctx.variables.insert(format!("t.{}", key), value.clone());
1994+ }
1995+ }
1996+ return Ok(None);
1997+ }
1998+
1573 // --- @deprecated annotation ---
1999 // --- @deprecated annotation ---
1574
2000
1575 if let Some(rest) = content.strip_prefix("@deprecated ") {
2001 if let Some(rest) = content.strip_prefix("@deprecated ") {
@@ -1686,6 +2112,67 @@ impl Parser {
1686 return Ok(Some(result_nodes));
2112 return Ok(Some(result_nodes));
1687 }
2113 }
1688
2114
2115+ // --- @layout (lightweight layout wrapper using @children) ---
2116+
2117+ if let Some(rest) = content.strip_prefix("@layout ") {
2118+ let filename = substitute_vars(rest.trim(), &ctx.variables);
2119+ let resolved = match &ctx.base_path {
2120+ Some(base) => base.join(&filename),
2121+ None => PathBuf::from(&filename),
2122+ };
2123+
2124+ if ctx.include_stack.contains(&resolved) {
2125+ ctx.diagnostics.push(Diagnostic {
2126+ line: line_num,
2127+ message: format!("circular layout '{}'", filename),
2128+ severity: Severity::Error,
2129+ source_line: Some(content.clone()),
2130+ });
2131+ return Ok(None);
2132+ }
2133+
2134+ let layout_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
2135+ cached.clone()
2136+ } else {
2137+ match std::fs::read_to_string(&resolved) {
2138+ Ok(text) => {
2139+ ctx.file_cache.insert(resolved.clone(), text.clone());
2140+ text
2141+ }
2142+ Err(e) => {
2143+ ctx.diagnostics.push(Diagnostic {
2144+ line: line_num,
2145+ message: format!("cannot load layout '{}': {}", filename, e),
2146+ severity: Severity::Error,
2147+ source_line: Some(content.clone()),
2148+ });
2149+ return Ok(None);
2150+ }
2151+ }
2152+ };
2153+
2154+ // Parse the body content (children of @layout)
2155+ let caller_children = self.parse_children(current_indent + 1, ctx);
2156+
2157+ // Parse the layout file
2158+ ctx.included_files.push(resolved.clone());
2159+ ctx.include_stack.push(resolved.clone());
2160+ let saved_base = ctx.base_path.clone();
2161+ ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
2162+
2163+ let layout_lines = preprocess(&layout_text);
2164+ let mut layout_parser = Parser { lines: layout_lines, pos: 0 };
2165+ let layout_nodes = layout_parser.parse_children(0, ctx);
2166+
2167+ ctx.base_path = saved_base;
2168+ ctx.include_stack.pop();
2169+
2170+ // Replace @children in layout with the caller's body content
2171+ let empty_slots: HashMap<String, Vec<Node>> = HashMap::new();
2172+ let result_nodes = replace_children_and_slots(layout_nodes, &caller_children, &empty_slots);
2173+ return Ok(Some(result_nodes));
2174+ }
2175+
1689 // --- @component definition (scoped @fn) ---
2176 // --- @component definition (scoped @fn) ---
1690
2177
1691 if let Some(rest) = content.strip_prefix("@component ") {
2178 if let Some(rest) = content.strip_prefix("@component ") {
@@ -2121,6 +2608,8 @@ const KNOWN_DIRECTIVES: &[&str] = &[
2121 "mixin", "assert",
2608 "mixin", "assert",
2122 "for", "component", "switch",
2609 "for", "component", "switch",
2123 "log",
2610 "log",
2611+ "markdown", "repeat", "with", "layout", "collection",
2612+ "manifest", "scope", "starting-style", "translations",
2124];
2613];
2125
2614
2126fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
2615fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
@@ -2379,6 +2868,16 @@ const KNOWN_ATTRS: &[&str] = &[
2379 "animate",
2868 "animate",
2380 // Critical CSS hint
2869 // Critical CSS hint
2381 "critical",
2870 "critical",
2871+ // CSS subgrid
2872+ "grid-template-columns", "grid-template-rows",
2873+ // Scroll-driven animations
2874+ "animation-timeline", "animation-range",
2875+ "view-timeline-name", "view-timeline-axis",
2876+ "scroll-timeline-name", "scroll-timeline-axis",
2877+ // Anchor positioning
2878+ "anchor-name", "position-anchor", "position-area", "inset-area",
2879+ // Drop caps
2880+ "initial-letter",
2382];
2881];
2383
2882
2384/// Attributes that expect purely numeric values (px-based) or values with CSS units.
2883/// Attributes that expect purely numeric values (px-based) or values with CSS units.
@@ -3424,10 +3923,14 @@ fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
3424 let start = i + 1;
3923 let start = i + 1;
3425 let mut end = start;
3924 let mut end = start;
3426 while end < chars.len()
3925 while end < chars.len()
3427− && (chars[end].is_alphanumeric() || chars[end] == '-' || chars[end] == '_')
3926+ && (chars[end].is_alphanumeric() || chars[end] == '-' || chars[end] == '_' || chars[end] == '.')
3428 {
3927 {
3429 end += 1;
3928 end += 1;
3430 }
3929 }
3930+ // Strip trailing dot (not part of name if at end)
3931+ while end > start && chars[end - 1] == '.' {
3932+ end -= 1;
3933+ }
3431 let name: String = chars[start..end].iter().collect();
3934 let name: String = chars[start..end].iter().collect();
3432
3935
3433 // Collect pipe filters: $name|filter1|filter2:arg
3936 // Collect pipe filters: $name|filter1|filter2:arg
@@ -3948,3 +4451,220 @@ fn json_value_to_string(v: &JsonValue) -> String {
3948 JsonValue::Array(_) | JsonValue::Object(_) => String::new(),
4451 JsonValue::Array(_) | JsonValue::Object(_) => String::new(),
3949 }
4452 }
3950}
4453}
4454+
4455+// ---------------------------------------------------------------------------
4456+// Markdown → HTML conversion (minimal subset)
4457+// ---------------------------------------------------------------------------
4458+
4459+fn markdown_to_html(lines: &[String]) -> String {
4460+ let mut html = String::new();
4461+ let mut in_list = false;
4462+ let mut in_code_block = false;
4463+ let mut code_lang = String::new();
4464+ let mut code_buf = String::new();
4465+ let mut para_buf = String::new();
4466+
4467+ let flush_para = |para: &mut String, out: &mut String| {
4468+ let trimmed = para.trim();
4469+ if !trimmed.is_empty() {
4470+ out.push_str("<p>");
4471+ out.push_str(&md_inline(trimmed));
4472+ out.push_str("</p>\n");
4473+ }
4474+ para.clear();
4475+ };
4476+
4477+ for line in lines {
4478+ let trimmed = line.trim();
4479+
4480+ // Fenced code blocks
4481+ if trimmed.starts_with("```") {
4482+ if in_code_block {
4483+ html.push_str("<pre><code");
4484+ if !code_lang.is_empty() {
4485+ html.push_str(&format!(" class=\"language-{}\"", code_lang));
4486+ }
4487+ html.push('>');
4488+ html.push_str(&html_escape_md(&code_buf));
4489+ html.push_str("</code></pre>\n");
4490+ code_buf.clear();
4491+ code_lang.clear();
4492+ in_code_block = false;
4493+ } else {
4494+ flush_para(&mut para_buf, &mut html);
4495+ code_lang = trimmed[3..].trim().to_string();
4496+ in_code_block = true;
4497+ }
4498+ continue;
4499+ }
4500+ if in_code_block {
4501+ if !code_buf.is_empty() {
4502+ code_buf.push('\n');
4503+ }
4504+ code_buf.push_str(trimmed);
4505+ continue;
4506+ }
4507+
4508+ // Blank line ends paragraph
4509+ if trimmed.is_empty() {
4510+ flush_para(&mut para_buf, &mut html);
4511+ if in_list {
4512+ html.push_str("</ul>\n");
4513+ in_list = false;
4514+ }
4515+ continue;
4516+ }
4517+
4518+ // Headings
4519+ if trimmed.starts_with("###### /* ") {
4520+ // skip
4521+ }
4522+ let heading_level = trimmed.bytes().take_while(|&b| b == b'#').count();
4523+ if (1..=6).contains(&heading_level) && trimmed.as_bytes().get(heading_level) == Some(&b' ') {
4524+ flush_para(&mut para_buf, &mut html);
4525+ if in_list { html.push_str("</ul>\n"); in_list = false; }
4526+ let text = &trimmed[heading_level + 1..];
4527+ html.push_str(&format!("<h{}>{}</h{}>\n", heading_level, md_inline(text), heading_level));
4528+ continue;
4529+ }
4530+
4531+ // Unordered list items
4532+ if (trimmed.starts_with("- ") || trimmed.starts_with("* ")) && trimmed.len() > 2 {
4533+ flush_para(&mut para_buf, &mut html);
4534+ if !in_list {
4535+ html.push_str("<ul>\n");
4536+ in_list = true;
4537+ }
4538+ html.push_str(&format!("<li>{}</li>\n", md_inline(&trimmed[2..])));
4539+ continue;
4540+ }
4541+
4542+ // Ordered list items
4543+ if let Some(dot_pos) = trimmed.find(". ") {
4544+ if dot_pos <= 3 && trimmed[..dot_pos].chars().all(|c| c.is_ascii_digit()) {
4545+ flush_para(&mut para_buf, &mut html);
4546+ if in_list { html.push_str("</ul>\n"); in_list = false; }
4547+ // We use <ol> but just emit <li> for simplicity
4548+ html.push_str(&format!("<li>{}</li>\n", md_inline(&trimmed[dot_pos + 2..])));
4549+ continue;
4550+ }
4551+ }
4552+
4553+ // Horizontal rule
4554+ if trimmed == "---" || trimmed == "***" || trimmed == "___" {
4555+ flush_para(&mut para_buf, &mut html);
4556+ if in_list { html.push_str("</ul>\n"); in_list = false; }
4557+ html.push_str("<hr>\n");
4558+ continue;
4559+ }
4560+
4561+ // Blockquote
4562+ if trimmed.starts_with("> ") {
4563+ flush_para(&mut para_buf, &mut html);
4564+ html.push_str(&format!("<blockquote><p>{}</p></blockquote>\n", md_inline(&trimmed[2..])));
4565+ continue;
4566+ }
4567+
4568+ // Otherwise, accumulate paragraph text
4569+ if !para_buf.is_empty() {
4570+ para_buf.push(' ');
4571+ }
4572+ para_buf.push_str(trimmed);
4573+ }
4574+
4575+ // Flush remaining
4576+ if in_list { html.push_str("</ul>\n"); }
4577+ flush_para(&mut para_buf, &mut html);
4578+
4579+ html
4580+}
4581+
4582+fn md_inline(text: &str) -> String {
4583+ let mut result = String::new();
4584+ let chars: Vec<char> = text.chars().collect();
4585+ let mut i = 0;
4586+
4587+ while i < chars.len() {
4588+ // Bold: **text** or __text__
4589+ if i + 1 < chars.len() && ((chars[i] == '*' && chars[i+1] == '*') || (chars[i] == '_' && chars[i+1] == '_')) {
4590+ let marker = chars[i];
4591+ if let Some(end) = find_closing_double(&chars, i + 2, marker) {
4592+ let inner: String = chars[i+2..end].iter().collect();
4593+ result.push_str("<strong>");
4594+ result.push_str(&md_inline(&inner));
4595+ result.push_str("</strong>");
4596+ i = end + 2;
4597+ continue;
4598+ }
4599+ }
4600+ // Italic: *text* or _text_
4601+ if (chars[i] == '*' || chars[i] == '_') && i + 1 < chars.len() && chars[i+1] != chars[i] {
4602+ let marker = chars[i];
4603+ if let Some(end) = find_closing_single(&chars, i + 1, marker) {
4604+ let inner: String = chars[i+1..end].iter().collect();
4605+ result.push_str("<em>");
4606+ result.push_str(&md_inline(&inner));
4607+ result.push_str("</em>");
4608+ i = end + 1;
4609+ continue;
4610+ }
4611+ }
4612+ // Inline code: `text`
4613+ if chars[i] == '`' {
4614+ if let Some(end) = chars[i+1..].iter().position(|&c| c == '`') {
4615+ let inner: String = chars[i+1..i+1+end].iter().collect();
4616+ result.push_str("<code>");
4617+ result.push_str(&html_escape_md(&inner));
4618+ result.push_str("</code>");
4619+ i = i + 2 + end;
4620+ continue;
4621+ }
4622+ }
4623+ // Links: [text](url)
4624+ if chars[i] == '[' {
4625+ if let Some(close_bracket) = chars[i+1..].iter().position(|&c| c == ']') {
4626+ let after = i + 1 + close_bracket + 1;
4627+ if after < chars.len() && chars[after] == '(' {
4628+ if let Some(close_paren) = chars[after+1..].iter().position(|&c| c == ')') {
4629+ let text: String = chars[i+1..i+1+close_bracket].iter().collect();
4630+ let url: String = chars[after+1..after+1+close_paren].iter().collect();
4631+ result.push_str(&format!("<a href=\"{}\">{}</a>", html_escape_md(&url), md_inline(&text)));
4632+ i = after + 2 + close_paren;
4633+ continue;
4634+ }
4635+ }
4636+ }
4637+ }
4638+ result.push(chars[i]);
4639+ i += 1;
4640+ }
4641+
4642+ result
4643+}
4644+
4645+fn find_closing_double(chars: &[char], start: usize, marker: char) -> Option<usize> {
4646+ let mut i = start;
4647+ while i + 1 < chars.len() {
4648+ if chars[i] == marker && chars[i+1] == marker {
4649+ return Some(i);
4650+ }
4651+ i += 1;
4652+ }
4653+ None
4654+}
4655+
4656+fn find_closing_single(chars: &[char], start: usize, marker: char) -> Option<usize> {
4657+ for i in start..chars.len() {
4658+ if chars[i] == marker {
4659+ return Some(i);
4660+ }
4661+ }
4662+ None
4663+}
4664+
4665+fn html_escape_md(s: &str) -> String {
4666+ s.replace('&', "&amp;")
4667+ .replace('<', "&lt;")
4668+ .replace('>', "&gt;")
4669+ .replace('"', "&quot;")
4670+}
tests/snapshots.rs+220
@@ -3450,3 +3450,223 @@ fn test_round_trip() {
3450 assert!(html2.contains("Hello"), "round-trip should preserve text content 'Hello'");
3450 assert!(html2.contains("Hello"), "round-trip should preserve text content 'Hello'");
3451 assert!(html2.contains("This is a test"), "round-trip should preserve text content 'This is a test'");
3451 assert!(html2.contains("This is a test"), "round-trip should preserve text content 'This is a test'");
3452}
3452}
3453+
3454+// ---------------------------------------------------------------------------
3455+// New improvement tests (batch)
3456+// ---------------------------------------------------------------------------
3457+
3458+#[test]
3459+fn snapshot_markdown_block() {
3460+ snapshot_test("markdown_block");
3461+}
3462+
3463+#[test]
3464+fn snapshot_repeat_directive() {
3465+ snapshot_test("repeat_directive");
3466+}
3467+
3468+#[test]
3469+fn snapshot_with_directive() {
3470+ snapshot_test("with_directive");
3471+}
3472+
3473+#[test]
3474+fn snapshot_scope_css() {
3475+ snapshot_test("scope_css");
3476+}
3477+
3478+#[test]
3479+fn snapshot_starting_style() {
3480+ snapshot_test("starting_style");
3481+}
3482+
3483+#[test]
3484+fn snapshot_manifest_directive() {
3485+ snapshot_test("manifest_directive");
3486+}
3487+
3488+#[test]
3489+fn snapshot_subgrid_css() {
3490+ snapshot_test("subgrid_css");
3491+}
3492+
3493+#[test]
3494+fn snapshot_anchor_positioning() {
3495+ snapshot_test("anchor_positioning");
3496+}
3497+
3498+#[test]
3499+fn snapshot_scroll_driven_animations() {
3500+ snapshot_test("scroll_driven_animations");
3501+}
3502+
3503+#[test]
3504+fn snapshot_initial_letter() {
3505+ snapshot_test("initial_letter");
3506+}
3507+
3508+// --- Inline unit tests for new features ---
3509+
3510+#[test]
3511+fn repeat_directive_basic() {
3512+ let output = compile("@repeat 3\n @text hello");
3513+ // Should contain 3 spans with "hello"
3514+ let count = output.matches("hello").count();
3515+ assert_eq!(count, 3, "expected 3 repetitions, got {}", count);
3516+}
3517+
3518+#[test]
3519+fn with_directive_rebinding() {
3520+ let output = compile("@let x hello\n@with $x as y\n @text $y");
3521+ assert!(output.contains("hello"), "expected @with to rebind variable");
3522+}
3523+
3524+#[test]
3525+fn markdown_renders_heading() {
3526+ let output = compile("@markdown\n # Title\n Some text");
3527+ assert!(output.contains("<h1>Title</h1>"), "markdown should render # as <h1>");
3528+ assert!(output.contains("<p>Some text</p>"), "markdown should render paragraphs");
3529+}
3530+
3531+#[test]
3532+fn markdown_renders_bold_italic() {
3533+ let output = compile("@markdown\n This is **bold** and *italic*");
3534+ assert!(output.contains("<strong>bold</strong>"), "markdown should render **bold**");
3535+ assert!(output.contains("<em>italic</em>"), "markdown should render *italic*");
3536+}
3537+
3538+#[test]
3539+fn markdown_renders_code() {
3540+ let output = compile("@markdown\n Use `code` here");
3541+ assert!(output.contains("<code>code</code>"), "markdown should render `code`");
3542+}
3543+
3544+#[test]
3545+fn markdown_renders_link() {
3546+ let output = compile("@markdown\n Visit [example](https://example.com)");
3547+ assert!(output.contains("<a href=\"https://example.com\">example</a>"), "markdown should render links");
3548+}
3549+
3550+#[test]
3551+fn markdown_renders_list() {
3552+ let output = compile("@markdown\n - one\n - two\n - three");
3553+ assert!(output.contains("<ul>"), "markdown should render unordered list");
3554+ assert!(output.contains("<li>one</li>"), "markdown should render list items");
3555+}
3556+
3557+#[test]
3558+fn scope_block_generates_css() {
3559+ let output = compile("@page Test\n@scope .card\n .title { color: red; }\n@text hello");
3560+ assert!(output.contains("@scope"), "should generate @scope CSS block");
3561+}
3562+
3563+#[test]
3564+fn starting_style_generates_css() {
3565+ let output = compile("@page Test\n@starting-style\n .fade { opacity: 0; }\n@text hello");
3566+ assert!(output.contains("@starting-style"), "should generate @starting-style CSS block");
3567+}
3568+
3569+#[test]
3570+fn manifest_generates_link() {
3571+ let output = compile("@page App\n@manifest My App\n display standalone\n@text hi");
3572+ assert!(output.contains("rel=\"manifest\""), "should generate manifest link");
3573+}
3574+
3575+#[test]
3576+fn subgrid_support() {
3577+ let output = compile("@el [grid-template-columns subgrid]");
3578+ assert!(output.contains("grid-template-columns:subgrid"), "should support CSS subgrid");
3579+}
3580+
3581+#[test]
3582+fn anchor_positioning_support() {
3583+ let output = compile("@el [anchor-name --my-anchor]\n @text anchor");
3584+ assert!(output.contains("anchor-name:--my-anchor"), "should support anchor-name CSS property");
3585+}
3586+
3587+#[test]
3588+fn scroll_driven_animation_support() {
3589+ let output = compile("@el [animation-timeline scroll()]\n @text scroll");
3590+ assert!(output.contains("animation-timeline:scroll()"), "should support animation-timeline CSS property");
3591+}
3592+
3593+#[test]
3594+fn initial_letter_support() {
3595+ let output = compile("@text [initial-letter 3] O");
3596+ assert!(output.contains("initial-letter:3"), "should support initial-letter CSS property");
3597+}
3598+
3599+#[test]
3600+fn position_area_support() {
3601+ let output = compile("@el [position-area top]\n @text tooltip");
3602+ assert!(output.contains("position-area:top"), "should support position-area CSS property");
3603+}
3604+
3605+// --- Snapshot tests for batch 2 ---
3606+
3607+#[test]
3608+fn snapshot_translations_i18n() {
3609+ snapshot_test("translations_i18n");
3610+}
3611+
3612+#[test]
3613+fn snapshot_pagination_each() {
3614+ snapshot_test("pagination_each");
3615+}
3616+
3617+// --- Inline tests for batch 2 ---
3618+
3619+#[test]
3620+fn translations_inject_variables() {
3621+ let output = compile("@translations\n en:\n hello Hi\n world Earth\n@text $t.hello $t.world");
3622+ assert!(output.contains("Hi"), "should inject translation for hello");
3623+ assert!(output.contains("Earth"), "should inject translation for world");
3624+}
3625+
3626+#[test]
3627+fn pagination_limits_items() {
3628+ let output = compile("@let _page 1\n@each $item in a,b,c,d,e [page 2]\n @text $item");
3629+ // Page 1 with page size 2 should show items a,b only
3630+ assert!(output.contains(">a<"), "page 1 should contain item a");
3631+ assert!(output.contains(">b<"), "page 1 should contain item b");
3632+ assert!(!output.contains(">c<"), "page 1 should NOT contain item c");
3633+}
3634+
3635+#[test]
3636+fn pagination_page_2() {
3637+ let output = compile("@let _page 2\n@each $item in a,b,c,d,e [page 2]\n @text $item");
3638+ assert!(output.contains(">c<"), "page 2 should contain item c");
3639+ assert!(output.contains(">d<"), "page 2 should contain item d");
3640+ assert!(!output.contains(">a<"), "page 2 should NOT contain item a");
3641+}
3642+
3643+#[test]
3644+fn image_auto_preload() {
3645+ let output = compile("@page Test\n@image logo.png\n@image hero.jpg");
3646+ assert!(output.contains("rel=\"preload\""), "should auto-preload images");
3647+ assert!(output.contains("logo.png"), "should preload logo.png");
3648+}
3649+
3650+#[test]
3651+fn source_map_generation() {
3652+ let input = "@page Test\n@text [bold] Hello";
3653+ let result = htmlang::parser::parse(input);
3654+ let map = htmlang::codegen::generate_source_map(&result.document, "test.hl");
3655+ assert!(map.contains("\"version\":3"), "source map should have version 3");
3656+ assert!(map.contains("test.hl"), "source map should reference source file");
3657+}
3658+
3659+#[test]
3660+fn parser_multiple_errors() {
3661+ let diags = parse_diagnostics("@unknown1\n@text hello\n@unknown2");
3662+ let errors: Vec<_> = diags.iter().filter(|d| d.severity == htmlang::parser::Severity::Error).collect();
3663+ assert!(errors.len() >= 2, "parser should report multiple errors, got {}", errors.len());
3664+}
3665+
3666+#[test]
3667+fn repeat_with_index() {
3668+ let output = compile("@repeat 3\n @text $_index");
3669+ assert!(output.contains("0"), "should have index 0");
3670+ assert!(output.contains("1"), "should have index 1");
3671+ assert!(output.contains("2"), "should have index 2");
3672+}
tests/snapshots/anchor_positioning.hlnew file+5
@@ -0,0 +1,5 @@
1+@el [anchor-name --tooltip-anchor, position relative]
2+ @text Hover me
3+
4+@el [position-anchor --tooltip-anchor, position absolute, position-area top]
5+ @text Tooltip content
tests/snapshots/initial_letter.hlnew file+3
@@ -0,0 +1,3 @@
1+@paragraph [line-height 1.6]
2+ @text [initial-letter 3] O
3+ nce upon a time in a land far away.
tests/snapshots/manifest_directive.hlnew file+12
@@ -0,0 +1,12 @@
1+@page My App
2+
3+@manifest My App
4+ short-name App
5+ start-url /
6+ display standalone
7+ theme-color #3b82f6
8+ background-color #ffffff
9+ description A sample PWA
10+
11+@column [padding 20]
12+ @text Hello
tests/snapshots/markdown_block.hlnew file+12
@@ -0,0 +1,12 @@
1+@page Markdown Test
2+
3+@column [padding 20]
4+ @markdown
5+ # Hello World
6+ This is a **bold** paragraph with *italic* text.
7+ Here is `inline code` and a [link](https://example.com).
8+ - Item one
9+ - Item two
10+ - Item three
11+ ---
12+ > A blockquote here.
tests/snapshots/pagination_each.hlnew file+3
@@ -0,0 +1,3 @@
1+@let _page 1
2+@each $item in a,b,c,d,e,f [page 2]
3+ @text $item
tests/snapshots/repeat_directive.hlnew file+2
@@ -0,0 +1,2 @@
1+@repeat 3
2+ @text [bold] Item $_index
tests/snapshots/scope_css.hlnew file+8
@@ -0,0 +1,8 @@
1+@page Scope Test
2+
3+@scope .card
4+ .title { font-weight: bold; }
5+ .body { color: #333; }
6+
7+@column [padding 20]
8+ @text Hello
tests/snapshots/scroll_driven_animations.hlnew file+9
@@ -0,0 +1,9 @@
1+@page Scroll Animation Test
2+
3+@keyframes fade-in
4+ from [opacity 0]
5+ to [opacity 1]
6+
7+@column [padding 20]
8+ @el [animation fade-in linear, animation-timeline scroll(), animation-range entry 0% cover 50%]
9+ @text Animated on scroll
tests/snapshots/starting_style.hlnew file+7
@@ -0,0 +1,7 @@
1+@page Starting Style Test
2+
3+@starting-style
4+ .fade-in { opacity: 0; }
5+
6+@column [padding 20]
7+ @text [opacity 1, transition opacity 0.3s ease] Hello
tests/snapshots/subgrid_css.hlnew file+5
@@ -0,0 +1,5 @@
1+@grid [grid-template-columns 1fr 2fr 1fr, gap 10]
2+ @el [grid-template-rows subgrid, row-span 2]
3+ @text First
4+ @text Second
5+ @text Third
tests/snapshots/translations_i18n.hlnew file+11
@@ -0,0 +1,11 @@
1+@translations
2+ en:
3+ greeting Hello
4+ farewell Goodbye
5+ fr:
6+ greeting Bonjour
7+ farewell Au revoir
8+
9+@column [padding 20]
10+ @text $t.greeting
11+ @text $t.farewell
tests/snapshots/with_directive.hlnew file+5
@@ -0,0 +1,5 @@
1+@let user-name Alice
2+@let greeting Hello
3+
4+@with $user-name as name
5+ @text $name says $greeting