@@ -114,6 +114,18 @@ struct ParseContext {
114114 font_faces: Vec<(String, String)>,
115115 /// @json-ld blocks
116116 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>,
117129}
118130
119131struct Parser {
@@ -163,6 +175,12 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
163175 mixins: HashMap::new(),
164176 mixin_lines: HashMap::new(),
165177 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,
166184 };
167185 let nodes = parser.parse_children(0, &mut ctx);
168186 validate_tree(&nodes, None, &mut ctx.diagnostics);
@@ -186,6 +204,10 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
186204 base_url: ctx.base_url,
187205 font_faces: ctx.font_faces,
188206 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),
189211 nodes,
190212 },
191213 diagnostics: ctx.diagnostics,
@@ -193,6 +215,37 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
193215 }
194216}
195217
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+
196249// ---------------------------------------------------------------------------
197250// Preprocessing: strip comments/blanks, collapse @raw blocks
198251// ---------------------------------------------------------------------------
@@ -309,11 +362,21 @@ impl Parser {
309362 Ok(Some(new_nodes)) => nodes.extend(new_nodes),
310363 Ok(None) => {}
311364 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+ };
312375 ctx.diagnostics.push(Diagnostic {
313376 line: e.line,
314377 message: e.message,
315378 severity: Severity::Error,
316− source_line: None,
379+ source_line: source,
317380 });
318381 // Skip forward past any deeper-indented children of the errored line
319382 while self.pos < self.lines.len()
@@ -475,6 +538,147 @@ impl Parser {
475538 return Ok(None);
476539 }
477540
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+
478682 if let Some(rest) = content.strip_prefix("@define ") {
479683 let rest = rest.trim();
480684 if let Some(bracket_start) = rest.find('[') {
@@ -596,7 +800,7 @@ impl Parser {
596800 None => PathBuf::from(&filename),
597801 };
598802
599− if ctx.include_stack.contains(&resolved) {
803+ if ctx.include_stack.contains(&resolved) || ctx.import_stack.contains(&resolved) {
600804 ctx.diagnostics.push(Diagnostic {
601805 line: line_num,
602806 message: format!("circular import '{}'", filename),
@@ -628,6 +832,7 @@ impl Parser {
628832
629833 ctx.included_files.push(resolved.clone());
630834 ctx.include_stack.push(resolved.clone());
835+ ctx.import_stack.push(resolved.clone());
631836 let saved_base = ctx.base_path.clone();
632837 ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
633838
@@ -695,6 +900,7 @@ impl Parser {
695900
696901 ctx.base_path = saved_base;
697902 ctx.include_stack.pop();
903+ ctx.import_stack.pop();
698904 return Ok(None); // No nodes emitted
699905 }
700906
@@ -878,6 +1084,93 @@ impl Parser {
8781084 return Ok(None);
8791085 }
8801086
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+
8811174 // --- @if / @else ---
8821175
8831176 if let Some(rest) = content.strip_prefix("@if ") {
@@ -1047,6 +1340,43 @@ impl Parser {
10471340 list_str.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
10481341 };
10491342
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+
10501380 // Collect body lines
10511381 let mut body_lines = Vec::new();
10521382 while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
@@ -1107,6 +1437,14 @@ impl Parser {
11071437 let saved_vars = ctx.variables.clone();
11081438 let mut all_nodes = Vec::new();
11091439
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+
11101448 let has_extra_vars = var_names.len() > 2
11111449 || (var_names.len() == 2 && items.first().map_or(false, |it| it.contains(' ')));
11121450
@@ -1219,6 +1557,45 @@ impl Parser {
12191557 return Ok(Some(all_nodes));
12201558 }
12211559
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+
12221599 // --- @match ---
12231600
12241601 if let Some(rest) = content.strip_prefix("@match ") {
@@ -1570,6 +1947,55 @@ impl Parser {
15701947 return Ok(None);
15711948 }
15721949
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+
15731999 // --- @deprecated annotation ---
15742000
15752001 if let Some(rest) = content.strip_prefix("@deprecated ") {
@@ -1686,6 +2112,67 @@ impl Parser {
16862112 return Ok(Some(result_nodes));
16872113 }
16882114
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+
16892176 // --- @component definition (scoped @fn) ---
16902177
16912178 if let Some(rest) = content.strip_prefix("@component ") {
@@ -2121,6 +2608,8 @@ const KNOWN_DIRECTIVES: &[&str] = &[
21212608 "mixin", "assert",
21222609 "for", "component", "switch",
21232610 "log",
2611+ "markdown", "repeat", "with", "layout", "collection",
2612+ "manifest", "scope", "starting-style", "translations",
21242613];
21252614
21262615fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
@@ -2379,6 +2868,16 @@ const KNOWN_ATTRS: &[&str] = &[
23792868 "animate",
23802869 // Critical CSS hint
23812870 "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",
23822881];
23832882
23842883/// 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 {
34243923 let start = i + 1;
34253924 let mut end = start;
34263925 while end < chars.len()
3427− && (chars[end].is_alphanumeric() || chars[end] == '-' || chars[end] == '_')
3926+ && (chars[end].is_alphanumeric() || chars[end] == '-' || chars[end] == '_' || chars[end] == '.')
34283927 {
34293928 end += 1;
34303929 }
3930+ // Strip trailing dot (not part of name if at end)
3931+ while end > start && chars[end - 1] == '.' {
3932+ end -= 1;
3933+ }
34313934 let name: String = chars[start..end].iter().collect();
34323935
34333936 // Collect pipe filters: $name|filter1|filter2:arg
@@ -3948,3 +4451,220 @@ fn json_value_to_string(v: &JsonValue) -> String {
39484451 JsonValue::Array(_) | JsonValue::Object(_) => String::new(),
39494452 }
39504453}
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('&', "&")
4667+ .replace('<', "<")
4668+ .replace('>', ">")
4669+ .replace('"', """)
4670+}