@@ -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>,
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(),
181+ import_stack: Vec::new(),
182+ translations: HashMap::new(),
183+ active_locale: None,
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),
191 diagnostics: ctx.diagnostics,
213 diagnostics: ctx.diagnostics,
@@ -193,6 +215,37 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
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);
226+fn collect_images_recursive(nodes: &[Node], hints: &mut Vec<crate::ast::PreloadHint>, max: usize) {
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 {
233+ && !src.starts_with("data:")
234+ && !src.starts_with('#')
236+ hints.push(crate::ast::PreloadHint {
238+ as_type: "image".to_string(),
244+ collect_images_recursive(&elem.children, hints, max);
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()),
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,
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);
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');
551+ LineContent::Raw(s) => {
552+ scope_content.push_str(s);
553+ scope_content.push('\n');
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)
563+ format!("@scope ({}) {{\n{}\n}}", scope_selector, trimmed)
565+ ctx.scope_blocks.push(block);
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');
579+ LineContent::Raw(s) => {
580+ ss_content.push_str(s);
581+ ss_content.push('\n');
586+ let trimmed = ss_content.trim().to_string();
587+ if !trimmed.is_empty() {
588+ ctx.starting_style_blocks.push(trimmed);
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()),
603+ let html = markdown_to_html(&md_lines);
604+ return Ok(Some(vec![Node::Raw(html)]));
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());
613+ let name = substitute_vars(&name, &ctx.variables);
614+ let mut manifest = crate::ast::ManifestConfig {
617+ start_url: "/".to_string(),
618+ display: "standalone".to_string(),
619+ background_color: None,
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);
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),
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()));
642+ manifest.icons.push((value, "192x192".to_string()));
651+ ctx.manifest = Some(manifest);
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);
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);
672+ ctx.variables.remove(alias);
674+ return Ok(Some(children));
676+ return Err(ParseError {
678+ message: "@with requires: @with $var as alias".to_string(),
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),
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 {
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());
@@ -695,6 +900,7 @@ impl Parser {
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
@@ -878,6 +1084,93 @@ impl Parser {
878 return Ok(None);
1084 return Ok(None);
1087+ // --- @collection (load multiple JSON files matching a glob pattern) ---
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())
1099+ return Err(ParseError {
1101+ message: "@collection requires: @collection $var \"pattern\"".to_string(),
1105+ let pat = rest.trim_matches('"');
1106+ ("_items".to_string(), pat.to_string())
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);
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();
1119+ let mut items = Vec::new();
1120+ if let Ok(entries) = std::fs::read_dir(parent) {
1121+ let mut paths: Vec<PathBuf> = entries
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])
1134+ name == file_pattern
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())
1149+ items.push((stem, text));
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));
881 // --- @if / @else ---
1174 // --- @if / @else ---
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()
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()
1361+ if let Some(size) = page_size {
1362+ let current_page: usize = ctx.variables.get("_page")
1363+ .and_then(|v| v.parse().ok())
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()
1374+ (page_items, Some((current_page, total_pages, size, total_items)))
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();
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());
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(' ')));
@@ -1219,6 +1557,45 @@ impl Parser {
1219 return Ok(Some(all_nodes));
1557 return Ok(Some(all_nodes));
1560+ // --- @repeat N (simple repetition) ---
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);
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());
1575+ if body_lines.is_empty() || count == 0 {
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,
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);
1595+ ctx.variables = saved_vars;
1596+ return Ok(Some(all_nodes));
1222 // --- @match ---
1599 // --- @match ---
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);
1950+ // --- @translations (i18n) ---
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())
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);
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);
1978+ .entry(current_locale.clone())
1979+ .or_insert_with(HashMap::new)
1980+ .insert(key, value);
1985+ // Set the active locale
1986+ if ctx.active_locale.is_none() {
1987+ ctx.active_locale = Some(active_locale.clone());
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());
1573 // --- @deprecated annotation ---
1999 // --- @deprecated annotation ---
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));
2115+ // --- @layout (lightweight layout wrapper using @children) ---
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),
2124+ if ctx.include_stack.contains(&resolved) {
2125+ ctx.diagnostics.push(Diagnostic {
2127+ message: format!("circular layout '{}'", filename),
2128+ severity: Severity::Error,
2129+ source_line: Some(content.clone()),
2134+ let layout_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
2137+ match std::fs::read_to_string(&resolved) {
2139+ ctx.file_cache.insert(resolved.clone(), text.clone());
2143+ ctx.diagnostics.push(Diagnostic {
2145+ message: format!("cannot load layout '{}': {}", filename, e),
2146+ severity: Severity::Error,
2147+ source_line: Some(content.clone()),
2154+ // Parse the body content (children of @layout)
2155+ let caller_children = self.parse_children(current_indent + 1, ctx);
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());
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);
2167+ ctx.base_path = saved_base;
2168+ ctx.include_stack.pop();
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));
1689 // --- @component definition (scoped @fn) ---
2176 // --- @component definition (scoped @fn) ---
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",
2611+ "markdown", "repeat", "with", "layout", "collection",
2612+ "manifest", "scope", "starting-style", "translations",
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",
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",
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] == '.')
3429 end += 1;
3928 end += 1;
3930+ // Strip trailing dot (not part of name if at end)
3931+ while end > start && chars[end - 1] == '.' {
3431 let name: String = chars[start..end].iter().collect();
3934 let name: String = chars[start..end].iter().collect();
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(),
4455+// ---------------------------------------------------------------------------
4456+// Markdown → HTML conversion (minimal subset)
4457+// ---------------------------------------------------------------------------
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();
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");
4477+ for line in lines {
4478+ let trimmed = line.trim();
4480+ // Fenced code blocks
4481+ if trimmed.starts_with("```") {
4483+ html.push_str("<pre><code");
4484+ if !code_lang.is_empty() {
4485+ html.push_str(&format!(" class=\"language-{}\"", code_lang));
4488+ html.push_str(&html_escape_md(&code_buf));
4489+ html.push_str("</code></pre>\n");
4492+ in_code_block = false;
4494+ flush_para(&mut para_buf, &mut html);
4495+ code_lang = trimmed[3..].trim().to_string();
4496+ in_code_block = true;
4501+ if !code_buf.is_empty() {
4502+ code_buf.push('\n');
4504+ code_buf.push_str(trimmed);
4508+ // Blank line ends paragraph
4509+ if trimmed.is_empty() {
4510+ flush_para(&mut para_buf, &mut html);
4512+ html.push_str("</ul>\n");
4519+ if trimmed.starts_with("###### /* ") {
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));
4531+ // Unordered list items
4532+ if (trimmed.starts_with("- ") || trimmed.starts_with("* ")) && trimmed.len() > 2 {
4533+ flush_para(&mut para_buf, &mut html);
4535+ html.push_str("<ul>\n");
4538+ html.push_str(&format!("<li>{}</li>\n", md_inline(&trimmed[2..])));
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..])));
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");
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..])));
4568+ // Otherwise, accumulate paragraph text
4569+ if !para_buf.is_empty() {
4570+ para_buf.push(' ');
4572+ para_buf.push_str(trimmed);
4576+ if in_list { html.push_str("</ul>\n"); }
4577+ flush_para(&mut para_buf, &mut html);
4582+fn md_inline(text: &str) -> String {
4583+ let mut result = String::new();
4584+ let chars: Vec<char> = text.chars().collect();
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>");
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>");
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>");
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;
4638+ result.push(chars[i]);
4645+fn find_closing_double(chars: &[char], start: usize, marker: char) -> Option<usize> {
4647+ while i + 1 < chars.len() {
4648+ if chars[i] == marker && chars[i+1] == marker {
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 {
4665+fn html_escape_md(s: &str) -> String {
4666+ s.replace('&', "&")
4667+ .replace('<', "<")
4668+ .replace('>', ">")
4669+ .replace('"', """)