@@ -260,6 +260,57 @@ fn preprocess(input: &str) -> Vec<Line> {
261 let indent = line.len() - line.trim_start().len();
261 let indent = line.len() - line.trim_start().len();
263+ // Inline `@markdown` and `@script` blocks keep their bodies verbatim:
264+ // in Markdown, blank lines separate paragraphs, `---` is a rule (not
265+ // a comment) and code indentation matters; in JavaScript, newlines,
266+ // `{...}` and `$` must reach the output untouched.
267+ let verbatim_body = trimmed == "@markdown"
268+ || trimmed == "@script"
269+ || trimmed.starts_with("@script ")
270+ || trimmed.starts_with("@script[");
274+ content: LineContent::Normal(trimmed.to_string()),
277+ let body_start = i + 1;
278+ let mut body_end = body_start;
279+ let mut j = body_start;
280+ while j < raw_lines.len() {
281+ let l = raw_lines[j];
282+ if l.trim().is_empty() {
286+ if l.len() - l.trim_start().len() <= indent {
292+ let body = &raw_lines[body_start..body_end];
293+ let body_indent = body
295+ .filter(|l| !l.trim().is_empty())
296+ .map(|l| l.len() - l.trim_start().len())
299+ if !body.is_empty() {
300+ let text: Vec<&str> = body
302+ .map(|l| l.get(body_indent..).unwrap_or("").trim_end())
306+ content: LineContent::Raw(text.join("\n")),
307+ line_num: body_start + 1,
310+ i = body_end.max(body_start);
263 // Handle @raw """..."""
314 // Handle @raw """..."""
264 if let Some(raw_rest) = trimmed.strip_prefix("@raw") {
315 if let Some(raw_rest) = trimmed.strip_prefix("@raw") {
265 let after_raw = raw_rest.trim_start();
316 let after_raw = raw_rest.trim_start();
@@ -305,9 +356,7 @@ fn preprocess(input: &str) -> Vec<Line> {
305 // Join continuation lines for multi-line attribute brackets
356 // Join continuation lines for multi-line attribute brackets
306 let first_line_num = i + 1;
357 let first_line_num = i + 1;
307 let mut full = trimmed.to_string();
358 let mut full = trimmed.to_string();
308− let mut depth: i32 = full.chars().filter(|&c| c == '[').count() as i32
309− - full.chars().filter(|&c| c == ']').count() as i32;
310− while depth > 0 && i + 1 < raw_lines.len() {
359+ while open_attr_depth(&full) > 0 && i + 1 < raw_lines.len() {
312 let next = raw_lines[i].trim();
361 let next = raw_lines[i].trim();
313 if next.is_empty() || next.starts_with("--") {
362 if next.is_empty() || next.starts_with("--") {
@@ -315,8 +364,6 @@ fn preprocess(input: &str) -> Vec<Line> {
316 full.push(' ');
365 full.push(' ');
317 full.push_str(next);
366 full.push_str(next);
318− depth += next.chars().filter(|&c| c == '[').count() as i32;
319− depth -= next.chars().filter(|&c| c == ']').count() as i32;
322 lines.push(Line {
369 lines.push(Line {
@@ -330,6 +377,48 @@ fn preprocess(input: &str) -> Vec<Line> {
380+/// Bracket depth left open at the end of `line`, counting only attribute
381+/// lists: a `[` that starts the line or follows an `@name` token (optionally
382+/// with one more word, as in `@let name [`). Brackets in text content, such
383+/// as `@text [bold] Use [ to open`, are ignored.
384+fn open_attr_depth(line: &str) -> i32 {
385+ if !line.starts_with('@') && !line.starts_with('[') {
388+ let bytes = line.as_bytes();
389+ let mut depth: i32 = 0;
390+ for (i, &b) in bytes.iter().enumerate() {
392+ b'[' if depth > 0 => depth += 1,
394+ // Is this the start of an attribute list?
395+ let before = line[..i].trim_end();
396+ let last_directive = before
401+ let tokens: Vec<&str> = last_directive.split_whitespace().collect();
402+ let starts_list = match tokens.as_slice() {
403+ [] => before.is_empty() || before.ends_with('>'),
404+ [name] => name.starts_with('@'),
405+ [name, _] => name.starts_with('@'),
410+ } else if before.ends_with(']') {
411+ // Text after a closed attribute list: stop scanning.
415+ b']' if depth > 0 => depth -= 1,
333// ---------------------------------------------------------------------------
422// ---------------------------------------------------------------------------
335// ---------------------------------------------------------------------------
424// ---------------------------------------------------------------------------
@@ -419,9 +508,16 @@ impl Parser {
419 if let Some(rest) = content.strip_prefix("@let ") {
508 if let Some(rest) = content.strip_prefix("@let ") {
420 let rest = rest.trim();
509 let rest = rest.trim();
511+ // A `"""` value opens a multi-line string, whose indented lines
512+ // are its content — not a function body.
513+ let opens_triple_quote = rest.split_once(' ').is_some_and(|(_, v)| {
515+ v.strip_prefix("= ").unwrap_or(v).trim_start().starts_with("\"\"\"")
422 // Check if next lines are indented (function/component definition)
517 // Check if next lines are indented (function/component definition)
423− let has_body =
518+ let has_body = !opens_triple_quote
519+ && self.pos < self.lines.len()
424− self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent;
520+ && self.lines[self.pos].indent > current_indent;
426 if has_body {
522 if has_body {
427 // Function definition: @let name $param1 $param2=default
523 // Function definition: @let name $param1 $param2=default
@@ -485,6 +581,8 @@ impl Parser {
486 // Multi-line @let with triple quotes: @let name """..."""
582 // Multi-line @let with triple quotes: @let name """..."""
487 let value_str;
583 let value_str;
584+ let quoted = value.starts_with('"') && value.ends_with('"') && value.len() >= 2
585+ || value.starts_with("\"\"\"");
488 let value = if let Some(after_open) = value.strip_prefix("\"\"\"") {
586 let value = if let Some(after_open) = value.strip_prefix("\"\"\"") {
489 if after_open.ends_with("\"\"\"") && after_open.len() >= 3 {
587 if after_open.ends_with("\"\"\"") && after_open.len() >= 3 {
490 // Single-line triple-quote: @let name """value"""
588 // Single-line triple-quote: @let name """value"""
@@ -525,7 +623,13 @@ impl Parser {
526 track_var_refs(value, &mut ctx.used_variables);
624 track_var_refs(value, &mut ctx.used_variables);
527 let value = substitute_vars(value, &ctx.variables);
625 let value = substitute_vars(value, &ctx.variables);
626+ // Quoted values are literal strings; only bare values are
627+ // evaluated as arithmetic (so `"1 / -1"` stays as written).
628+ let value = if quoted {
528− let value = evaluate_arithmetic(&value);
631+ evaluate_arithmetic(&value)
529 if name.starts_with("--") {
633 if name.starts_with("--") {
530 // CSS custom property
634 // CSS custom property
531 ctx.css_vars.push((name.to_string(), value.clone()));
635 ctx.css_vars.push((name.to_string(), value.clone()));
@@ -682,7 +786,7 @@ impl Parser {
682 while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
786 while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
683 match &self.lines[self.pos].content {
787 match &self.lines[self.pos].content {
684 LineContent::Normal(s) => md_lines.push(s.clone()),
788 LineContent::Normal(s) => md_lines.push(s.clone()),
685− LineContent::Raw(s) => md_lines.push(s.clone()),
789+ LineContent::Raw(s) => md_lines.extend(s.lines().map(String::from)),
687 self.pos += 1;
791 self.pos += 1;
@@ -1636,6 +1740,7 @@ impl Parser {
1637 if let Some(rest) = content.strip_prefix("@if ") {
1741 if let Some(rest) = content.strip_prefix("@if ") {
1638 let rest = rest.trim();
1742 let rest = rest.trim();
1743+ track_var_refs(rest, &mut ctx.used_variables);
1639 let condition = substitute_vars(rest, &ctx.variables);
1744 let condition = substitute_vars(rest, &ctx.variables);
1640 let result = evaluate_condition(&condition);
1745 let result = evaluate_condition(&condition);
@@ -1658,6 +1763,7 @@ impl Parser {
1658 let trimmed = s.trim();
1763 let trimmed = s.trim();
1659 if let Some(else_if_cond) = trimmed.strip_prefix("@else if ") {
1764 if let Some(else_if_cond) = trimmed.strip_prefix("@else if ") {
1660 self.pos += 1; // consume @else if
1765 self.pos += 1; // consume @else if
1766+ track_var_refs(else_if_cond, &mut ctx.used_variables);
1661 let cond = substitute_vars(else_if_cond.trim(), &ctx.variables);
1767 let cond = substitute_vars(else_if_cond.trim(), &ctx.variables);
1662 let cond_result = evaluate_condition(&cond);
1768 let cond_result = evaluate_condition(&cond);
1663 let mut body = Vec::new();
1769 let mut body = Vec::new();
@@ -1722,6 +1828,7 @@ impl Parser {
1723 if let Some(rest) = content.strip_prefix("@unless ") {
1829 if let Some(rest) = content.strip_prefix("@unless ") {
1724 let rest = rest.trim();
1830 let rest = rest.trim();
1831+ track_var_refs(rest, &mut ctx.used_variables);
1725 let condition = substitute_vars(rest, &ctx.variables);
1832 let condition = substitute_vars(rest, &ctx.variables);
1726 let result = !evaluate_condition(&condition);
1833 let result = !evaluate_condition(&condition);
1727 let mut body_lines = Vec::new();
1834 let mut body_lines = Vec::new();
@@ -1781,8 +1888,16 @@ impl Parser {
1781 let var_name = var_names[0].clone();
1888 let var_name = var_names[0].clone();
1782 let index_var = var_names.get(1).cloned();
1889 let index_var = var_names.get(1).cloned();
1784− let list_str = substitute_vars(&list_str, &ctx.variables);
1891+ // Strip a trailing `[page N]` pagination suffix so it doesn't end
1892+ // up in the last item; it's parsed from the raw line below.
1893+ let list_str = match list_str.rfind("[page ") {
1894+ Some(pos) if list_str.trim_end().ends_with(']') => {
1895+ list_str[..pos].trim_end().to_string()
1785 track_var_refs(&list_str, &mut ctx.used_variables);
1899 track_var_refs(&list_str, &mut ctx.used_variables);
1900+ let list_str = substitute_vars(&list_str, &ctx.variables);
1786 // Support range syntax: @each $i in 1..5 or @each $i in 0..100 step 10
1901 // Support range syntax: @each $i in 1..5 or @each $i in 0..100 step 10
1787 let items: Vec<String> = if let Some((start_s, rest)) = list_str.split_once("..") {
1902 let items: Vec<String> = if let Some((start_s, rest)) = list_str.split_once("..") {
1788 let (end_s, step) = if let Some((e, s)) = rest.split_once(" step ") {
1903 let (end_s, step) = if let Some((e, s)) = rest.split_once(" step ") {
@@ -1792,23 +1907,7 @@ impl Parser {
1793 if let (Ok(start), Ok(end)) = (start_s.trim().parse::<i64>(), end_s.parse::<i64>())
1908 if let (Ok(start), Ok(end)) = (start_s.trim().parse::<i64>(), end_s.parse::<i64>())
1795− if start <= end {
1910+ numeric_range(start, end, step)
1796− let mut items = Vec::new();
1799− items.push(n.to_string());
1804− let mut items = Vec::new();
1807− items.push(n.to_string());
1812 } else {
1911 } else {
1813 list_str
1912 list_str
1814 .split(',')
1913 .split(',')
@@ -1847,11 +1946,13 @@ impl Parser {
1847 .variables
1946 .variables
1848 .get("_page")
1947 .get("_page")
1849 .and_then(|v| v.parse().ok())
1948 .and_then(|v| v.parse().ok())
1850− .unwrap_or(1);
1949+ .unwrap_or(1)
1951+ let size = size.max(1);
1851 let total_items = items.len();
1952 let total_items = items.len();
1852 let total_pages = total_items.div_ceil(size);
1953 let total_pages = total_items.div_ceil(size);
1853− let start = (current_page - 1) * size;
1954+ let start = (current_page - 1).saturating_mul(size);
1854− let end = (start + size).min(total_items);
1955+ let end = start.saturating_add(size).min(total_items);
1855 let page_items: Vec<String> = if start < total_items {
1956 let page_items: Vec<String> = if start < total_items {
1856 items[start..end].to_vec()
1957 items[start..end].to_vec()
1857 } else {
1958 } else {
@@ -1909,7 +2010,9 @@ impl Parser {
1909 lines: adjusted,
2010 lines: adjusted,
2013+ let saved_vars = ctx.variables.clone();
1912 let nodes = body_parser.parse_children(0, ctx);
2014 let nodes = body_parser.parse_children(0, ctx);
2015+ ctx.variables = saved_vars;
1913 return Ok(Some(nodes));
2016 return Ok(Some(nodes));
@@ -1986,8 +2089,8 @@ impl Parser {
1986 message: "@for requires: @for $var in start..end".to_string(),
2089 message: "@for requires: @for $var in start..end".to_string(),
1989− let range_str = substitute_vars(&range_str, &ctx.variables);
1990 track_var_refs(&range_str, &mut ctx.used_variables);
2092 track_var_refs(&range_str, &mut ctx.used_variables);
2093+ let range_str = substitute_vars(&range_str, &ctx.variables);
1992 let items: Vec<String> = if let Some((start_s, rest_range)) = range_str.split_once("..")
2095 let items: Vec<String> = if let Some((start_s, rest_range)) = range_str.split_once("..")
@@ -1998,23 +2101,7 @@ impl Parser {
1999 if let (Ok(start), Ok(end)) = (start_s.trim().parse::<i64>(), end_s.parse::<i64>())
2102 if let (Ok(start), Ok(end)) = (start_s.trim().parse::<i64>(), end_s.parse::<i64>())
2001− if start <= end {
2104+ numeric_range(start, end, step)
2002− let mut items = Vec::new();
2005− items.push(n.to_string());
2010− let mut items = Vec::new();
2013− items.push(n.to_string());
2018 } else {
2105 } else {
2019 return Err(ParseError {
2106 return Err(ParseError {
2020 line: line_num,
2107 line: line_num,
@@ -2920,8 +3007,10 @@ impl Parser {
2921 // --- Bare text ---
3008 // --- Bare text ---
2923− let var_warnings = check_undefined_vars(&content, &ctx.variables, line_num);
3011+ check_undefined_vars(&content, &ctx.variables, line_num, current_indent);
2924 ctx.diagnostics.extend(var_warnings);
3012 ctx.diagnostics.extend(var_warnings);
3013+ track_var_refs(&content, &mut ctx.used_variables);
2925 let segments = parse_text_segments(&content, ctx);
3014 let segments = parse_text_segments(&content, ctx);
2926 Ok(Some(vec![Node::Text(segments)]))
3015 Ok(Some(vec![Node::Text(segments)]))
@@ -2997,7 +3086,13 @@ impl Parser {
2998 .find(|a| a.key == *param)
3087 .find(|a| a.key == *param)
2999 .and_then(|a| a.value.clone())
3088 .and_then(|a| a.value.clone())
3090+ // Positional fallback, unless that argument is named for
3091+ // a different parameter.
3093+ .filter(|a| !fn_def.params.contains(&a.key))
3000− .or_else(|| args.get(i).and_then(|a| a.value.clone()))
3094+ .and_then(|a| a.value.clone())
3001 .or_else(|| fn_def.defaults.get(param).cloned())
3096 .or_else(|| fn_def.defaults.get(param).cloned())
3002 .unwrap_or_default();
3097 .unwrap_or_default();
3003 ctx.variables.insert(param.clone(), value);
3098 ctx.variables.insert(param.clone(), value);
@@ -3196,6 +3291,7 @@ fn parse_single_element(
3198 let rest = rest.trim().to_string();
3293 let rest = rest.trim().to_string();
3294+ track_var_refs(&rest, &mut ctx.used_variables);
3200 // For @link, first token of rest is URL, remainder is inline text
3296 // For @link, first token of rest is URL, remainder is inline text
3201 let mut children = Vec::new();
3297 let mut children = Vec::new();
@@ -3588,10 +3684,13 @@ fn suggest_var_name(input: &str, vars: &HashMap<String, String>) -> Option<Strin
3590/// Check for undefined `$var` references and return "did you mean?" diagnostics.
3686/// Check for undefined `$var` references and return "did you mean?" diagnostics.
3687+/// `indent` is the line's leading whitespace, which `input` has had trimmed;
3688+/// reported columns and source lines include it.
3591fn check_undefined_vars(
3689fn check_undefined_vars(
3592 input: &str,
3690 input: &str,
3593 vars: &HashMap<String, String>,
3691 vars: &HashMap<String, String>,
3594 line_num: usize,
3692 line_num: usize,
3595) -> Vec<Diagnostic> {
3694) -> Vec<Diagnostic> {
3596 let mut warnings = Vec::new();
3695 let mut warnings = Vec::new();
3597 if !input.contains('$') {
3696 if !input.contains('$') {
@@ -3625,13 +3724,13 @@ fn check_undefined_vars(
3626 warnings.push(Diagnostic {
3725 warnings.push(Diagnostic {
3627 line: line_num,
3726 line: line_num,
3628− column: Some(col),
3727+ column: Some(indent + col),
3629 message: format!(
3728 message: format!(
3630 "undefined variable '${}', did you mean '${}'?",
3729 "undefined variable '${}', did you mean '${}'?",
3631 name, closest
3730 name, closest
3633 severity: Severity::Warning,
3732 severity: Severity::Warning,
3634− source_line: Some(input.to_string()),
3733+ source_line: Some(format!("{}{}", " ".repeat(indent), input)),
3637 i = end;
3736 i = end;
@@ -4006,8 +4105,8 @@ const NUMERIC_ATTRS: &[&str] = &[
4008const CSS_UNIT_SUFFIXES: &[&str] = &[
4107const CSS_UNIT_SUFFIXES: &[&str] = &[
4009− "%", "rem", "em", "vh", "vw", "vmin", "vmax", "dvh", "svh", "lvh", "ch", "ex", "cm", "mm",
4108+ "px", "%", "rem", "em", "vh", "vw", "vmin", "vmax", "dvh", "svh", "lvh", "ch", "ex", "cm",
4010− "in", "pt", "pc", "fr",
4109+ "mm", "in", "pt", "pc", "fr",
4013fn has_css_unit(value: &str) -> bool {
4112fn has_css_unit(value: &str) -> bool {
@@ -4559,9 +4658,12 @@ fn split_commas(input: &str) -> Vec<&str> {
4559 let mut parts = Vec::new();
4658 let mut parts = Vec::new();
4560 let mut start = 0;
4659 let mut start = 0;
4561 let mut depth = 0;
4660 let mut depth = 0;
4661+ let mut in_quotes = false;
4563 for (i, c) in input.char_indices() {
4663 for (i, c) in input.char_indices() {
4564 match c {
4664 match c {
4665+ '"' => in_quotes = !in_quotes,
4666+ _ if in_quotes => {}
4565 '[' | '(' => depth += 1,
4667 '[' | '(' => depth += 1,
4566 ']' | ')' => depth -= 1,
4668 ']' | ')' => depth -= 1,
4567 ',' if depth == 0 => {
4669 ',' if depth == 0 => {
@@ -4693,6 +4795,31 @@ fn parse_text_segments(input: &str, ctx: &mut ParseContext) -> Vec<TextSegment>
4693// Condition evaluation for @if
4795// Condition evaluation for @if
4694// ---------------------------------------------------------------------------
4796// ---------------------------------------------------------------------------
4798+/// Inclusive integer range from `start` to `end` (counting down when
4799+/// `start > end`), stepping by `step`. Stops instead of overflowing.
4800+fn numeric_range(start: i64, end: i64, step: i64) -> Vec<String> {
4801+ let mut items = Vec::new();
4805+ items.push(n.to_string());
4806+ match n.checked_add(step) {
4807+ Some(next) => n = next,
4813+ items.push(n.to_string());
4814+ match n.checked_sub(step) {
4815+ Some(next) => n = next,
4696fn evaluate_condition(condition: &str) -> bool {
4823fn evaluate_condition(condition: &str) -> bool {
4697 // Ternary expression support: condition ? true_val : false_val
4824 // Ternary expression support: condition ? true_val : false_val
4698 // (just check the condition part, the ternary is handled in substitute_ternary)
4825 // (just check the condition part, the ternary is handled in substitute_ternary)
@@ -5381,6 +5508,9 @@ fn validate_tree(
5382fn parse_hex_rgb(s: &str) -> Option<(u8, u8, u8)> {
5509fn parse_hex_rgb(s: &str) -> Option<(u8, u8, u8)> {
5383 let s = s.strip_prefix('#')?;
5510 let s = s.strip_prefix('#')?;
5511+ if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
5384 match s.len() {
5514 match s.len() {
5386 let r = u8::from_str_radix(&s[0..1], 16).ok()?;
5516 let r = u8::from_str_radix(&s[0..1], 16).ok()?;
@@ -5535,7 +5665,7 @@ fn parse_keyframe_line(line: &str) -> Option<String> {
5535 let inner = &rest[1..rest.len() - 1];
5665 let inner = &rest[1..rest.len() - 1];
5536 // Parse comma-separated key-value pairs into CSS
5666 // Parse comma-separated key-value pairs into CSS
5537 let mut css = String::new();
5667 let mut css = String::new();
5538− for part in inner.split(',') {
5668+ for part in split_commas(inner) {
5539 let part = part.trim();
5669 let part = part.trim();
5540 if part.is_empty() {
5670 if part.is_empty() {
5541 continue;
5671 continue;
@@ -5584,9 +5714,9 @@ fn replace_extends_slots(nodes: Vec<Node>, slot_nodes: &HashMap<String, Vec<Node
5584fn apply_filter(value: &str, filter: &str) -> String {
5714fn apply_filter(value: &str, filter: &str) -> String {
5585 if let Some(arg) = filter.strip_prefix("truncate:") {
5715 if let Some(arg) = filter.strip_prefix("truncate:") {
5586 if let Ok(n) = arg.parse::<usize>()
5716 if let Ok(n) = arg.parse::<usize>()
5587− && value.len() > n
5717+ && value.chars().count() > n
5589− return format!("{}...", &value[..n]);
5719+ return format!("{}...", value.chars().take(n).collect::<String>());
5591 return value.to_string();
5721 return value.to_string();
@@ -5656,7 +5786,7 @@ fn apply_filter(value: &str, filter: &str) -> String {
5658 "trim" => value.trim().to_string(),
5788 "trim" => value.trim().to_string(),
5659− "length" | "len" => value.len().to_string(),
5789+ "length" | "len" => value.chars().count().to_string(),
5660 "reverse" => value.chars().rev().collect(),
5790 "reverse" => value.chars().rev().collect(),
5661 _ => value.to_string(),
5791 _ => value.to_string(),
@@ -5896,7 +6026,7 @@ fn parse_json_value(chars: &[char], mut pos: usize) -> Option<(JsonValue, usize)
5898fn parse_json_string(chars: &[char], mut pos: usize) -> Option<(String, usize)> {
6028fn parse_json_string(chars: &[char], mut pos: usize) -> Option<(String, usize)> {
5899− if chars[pos] != '"' {
6029+ if chars.get(pos) != Some(&'"') {
5900 return None;
6030 return None;
5902 pos += 1;
6032 pos += 1;
@@ -5909,6 +6039,28 @@ fn parse_json_string(chars: &[char], mut pos: usize) -> Option<(String, usize)>
5909 'n' => s.push('\n'),
6039 'n' => s.push('\n'),
5910 't' => s.push('\t'),
6040 't' => s.push('\t'),
5911 'r' => s.push('\r'),
6041 'r' => s.push('\r'),
6042+ 'b' => s.push('\u{8}'),
6043+ 'f' => s.push('\u{c}'),
6045+ let hex4 = |at: usize| -> Option<u32> {
6046+ let digits: String = chars.get(at..at + 4)?.iter().collect();
6047+ u32::from_str_radix(&digits, 16).ok()
6049+ let hi = hex4(pos + 1)?;
6051+ let code = if (0xD800..0xDC00).contains(&hi)
6052+ && chars.get(pos + 1) == Some(&'\\')
6053+ && chars.get(pos + 2) == Some(&'u')
6054+ && let Some(lo) = hex4(pos + 3)
6055+ && (0xDC00..0xE000).contains(&lo)
6058+ 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00)
6062+ s.push(char::from_u32(code).unwrap_or('\u{FFFD}'));
5913 s.push('\\');
6065 s.push('\\');
5914 s.push(chars[pos]);
6066 s.push(chars[pos]);
@@ -6115,11 +6267,10 @@ fn fetch_url_blocking(url: &str) -> Result<String, String> {
6115 .read_to_end(&mut response)
6267 .read_to_end(&mut response)
6116 .map_err(|e| format!("read failed: {}", e))?;
6268 .map_err(|e| format!("read failed: {}", e))?;
6118− let response_str = String::from_utf8_lossy(&response);
6119− // Split headers and body
6270+ // Split headers and body (on bytes, so chunk sizes stay byte counts)
6120− if let Some(body_start) = response_str.find("\r\n\r\n") {
6271+ if let Some(body_start) = response.windows(4).position(|w| w == b"\r\n\r\n") {
6121− let headers = &response_str[..body_start];
6272+ let headers = String::from_utf8_lossy(&response[..body_start]);
6122− let body = &response_str[body_start + 4..];
6273+ let body = &response[body_start + 4..];
6124 // Check status code
6275 // Check status code
6125 if let Some(first_line) = headers.lines().next()
6276 if let Some(first_line) = headers.lines().next()
@@ -6136,40 +6287,33 @@ fn fetch_url_blocking(url: &str) -> Result<String, String> {
6136 .to_lowercase()
6287 .to_lowercase()
6137 .contains("transfer-encoding: chunked")
6288 .contains("transfer-encoding: chunked")
6139− return Ok(decode_chunked(body));
6290+ return Ok(String::from_utf8_lossy(&decode_chunked(body)).into_owned());
6142− Ok(body.to_string())
6293+ Ok(String::from_utf8_lossy(body).into_owned())
6143 } else {
6294 } else {
6144 Err("malformed HTTP response".to_string())
6295 Err("malformed HTTP response".to_string())
6148−fn decode_chunked(body: &str) -> String {
6299+fn decode_chunked(body: &[u8]) -> Vec<u8> {
6149− let mut result = String::new();
6300+ let mut result = Vec::new();
6150 let mut rest = body;
6301 let mut rest = body;
6151− loop {
6302+ while let Some(size_end) = rest.windows(2).position(|w| w == b"\r\n") {
6152− let rest_trimmed = rest.trim_start();
6303+ let size_line = String::from_utf8_lossy(&rest[..size_end]);
6153− if rest_trimmed.is_empty() {
6304+ // Chunk extensions (`;name=value`) may follow the size.
6154− break;
6305+ let size_str = size_line.split(';').next().unwrap_or("").trim();
6156− let size_end = rest_trimmed.find("\r\n").unwrap_or(rest_trimmed.len());
6157− let size_str = &rest_trimmed[..size_end];
6158− let size = usize::from_str_radix(size_str.trim(), 16).unwrap_or(0);
6306+ let size = usize::from_str_radix(size_str, 16).unwrap_or(0);
6159 if size == 0 {
6307 if size == 0 {
6162− let chunk_start = size_end + 2;
6310+ let chunk = &rest[size_end + 2..];
6163− if chunk_start + size <= rest_trimmed.len() {
6311+ if size > chunk.len() {
6164− result.push_str(&rest_trimmed[chunk_start..chunk_start + size]);
6312+ result.extend_from_slice(chunk);
6165− rest = &rest_trimmed[chunk_start + size..];
6166− if rest.starts_with("\r\n") {
6170− result.push_str(&rest_trimmed[chunk_start..]);
6315+ result.extend_from_slice(&chunk[..size]);
6316+ rest = chunk[size..].strip_prefix(b"\r\n").unwrap_or(&chunk[size..]);
@@ -6179,35 +6323,52 @@ fn decode_chunked(body: &str) -> String {
6179// ---------------------------------------------------------------------------
6323// ---------------------------------------------------------------------------
6181fn set_svg_attr(svg: &str, attr_name: &str, value: &str) -> String {
6325fn set_svg_attr(svg: &str, attr_name: &str, value: &str) -> String {
6182− // If the SVG already has this attribute, replace it
6326+ // Only the opening <svg ...> tag is touched, so child attributes such as
6327+ // `stroke-width` are left alone.
6328+ let Some(tag_start) = svg.find("<svg") else {
6329+ return svg.to_string();
6331+ let tag_end = svg[tag_start..]
6333+ .map(|p| tag_start + p)
6334+ .unwrap_or(svg.len());
6335+ let tag = &svg[tag_start..tag_end];
6337+ // Replace an existing attribute (must be preceded by whitespace so that
6338+ // `width` doesn't match `stroke-width`).
6183 let pattern = format!("{}=\"", attr_name);
6339 let pattern = format!("{}=\"", attr_name);
6340+ let existing = tag.match_indices(&pattern).find(|(pos, _)| {
6344+ .is_some_and(char::is_whitespace)
6184− if let Some(pos) = svg.find(&pattern) {
6346+ if let Some((pos, _)) = existing {
6185− let after = &svg[pos + pattern.len()..];
6347+ let value_start = tag_start + pos + pattern.len();
6186− if let Some(end) = after.find('"') {
6348+ if let Some(end) = svg[value_start..tag_end].find('"') {
6187 let mut result = String::with_capacity(svg.len());
6349 let mut result = String::with_capacity(svg.len());
6188− result.push_str(&svg[..pos]);
6350+ result.push_str(&svg[..value_start]);
6189− result.push_str(attr_name);
6190− result.push_str("=\"");
6191 result.push_str(value);
6351 result.push_str(value);
6192− result.push('"');
6352+ result.push_str(&svg[value_start + end..]);
6193− result.push_str(&after[end + 1..]);
6194 return result;
6353 return result;
6197− // Otherwise inject it into the opening <svg tag
6357+ // Otherwise inject it into the opening tag (before a self-closing `/`).
6198− if let Some(pos) = svg.find("<svg") {
6358+ let insert_at = if svg[..tag_end].ends_with('/') {
6199− let tag_end = svg[pos..].find('>').map(|p| pos + p).unwrap_or(svg.len());
6359+ tag_end - 1
6200− let mut result = String::with_capacity(svg.len() + attr_name.len() + value.len() + 4);
6363+ let mut result = String::with_capacity(svg.len() + attr_name.len() + value.len() + 4);
6201− result.push_str(&svg[..tag_end]);
6364+ result.push_str(svg[..insert_at].trim_end());
6202− result.push(' ');
6365+ result.push(' ');
6203− result.push_str(attr_name);
6366+ result.push_str(attr_name);
6204− result.push_str("=\"");
6367+ result.push_str("=\"");
6205− result.push_str(value);
6368+ result.push_str(value);
6206− result.push('"');
6369+ result.push('"');
6207− result.push_str(&svg[tag_end..]);
6370+ result.push_str(&svg[insert_at..]);
6208− return result;
6371+ result
6213// ---------------------------------------------------------------------------
6374// ---------------------------------------------------------------------------
@@ -6216,7 +6377,8 @@ fn set_svg_attr(svg: &str, attr_name: &str, value: &str) -> String {
6217fn markdown_to_html(lines: &[String]) -> String {
6378fn markdown_to_html(lines: &[String]) -> String {
6218 let mut html = String::new();
6379 let mut html = String::new();
6219− let mut in_list = false;
6380+ // Tag of the list currently open ("ul" or "ol"), if any.
6381+ let mut list: Option<&'static str> = None;
6220 let mut in_code_block = false;
6382 let mut in_code_block = false;
6221 let mut code_lang = String::new();
6383 let mut code_lang = String::new();
6222 let mut code_buf = String::new();
6384 let mut code_buf = String::new();
@@ -6231,6 +6393,20 @@ fn markdown_to_html(lines: &[String]) -> String {
6232 para.clear();
6394 para.clear();
6396+ let close_list = |list: &mut Option<&'static str>, out: &mut String| {
6397+ if let Some(tag) = list.take() {
6398+ out.push_str(&format!("</{}>\n", tag));
6401+ let open_list = |list: &mut Option<&'static str>, tag: &'static str, out: &mut String| {
6402+ if *list != Some(tag) {
6403+ if let Some(prev) = list.take() {
6404+ out.push_str(&format!("</{}>\n", prev));
6406+ out.push_str(&format!("<{}>\n", tag));
6235 for line in lines {
6411 for line in lines {
6236 let trimmed = line.trim();
6412 let trimmed = line.trim();
@@ -6250,6 +6426,7 @@ fn markdown_to_html(lines: &[String]) -> String {
6250 in_code_block = false;
6426 in_code_block = false;
6251 } else {
6427 } else {
6252 flush_para(&mut para_buf, &mut html);
6428 flush_para(&mut para_buf, &mut html);
6429+ close_list(&mut list, &mut html);
6253 code_lang = after_fence.trim().to_string();
6430 code_lang = after_fence.trim().to_string();
6254 in_code_block = true;
6431 in_code_block = true;
@@ -6259,32 +6436,24 @@ fn markdown_to_html(lines: &[String]) -> String {
6259 if !code_buf.is_empty() {
6436 if !code_buf.is_empty() {
6260 code_buf.push('\n');
6437 code_buf.push('\n');
6439+ // Keep indentation inside code blocks.
6262− code_buf.push_str(trimmed);
6440+ code_buf.push_str(line.trim_end());
6263 continue;
6441 continue;
6266 // Blank line ends paragraph
6444 // Blank line ends paragraph
6267 if trimmed.is_empty() {
6445 if trimmed.is_empty() {
6268 flush_para(&mut para_buf, &mut html);
6446 flush_para(&mut para_buf, &mut html);
6269− if in_list {
6447+ close_list(&mut list, &mut html);
6270− html.push_str("</ul>\n");
6273 continue;
6448 continue;
6276 // Headings
6451 // Headings
6277− if trimmed.starts_with("###### /* ") {
6280 let heading_level = trimmed.bytes().take_while(|&b| b == b'#').count();
6452 let heading_level = trimmed.bytes().take_while(|&b| b == b'#').count();
6281 if (1..=6).contains(&heading_level) && trimmed.as_bytes().get(heading_level) == Some(&b' ')
6453 if (1..=6).contains(&heading_level) && trimmed.as_bytes().get(heading_level) == Some(&b' ')
6283 flush_para(&mut para_buf, &mut html);
6455 flush_para(&mut para_buf, &mut html);
6284− if in_list {
6456+ close_list(&mut list, &mut html);
6285− html.push_str("</ul>\n");
6288 let text = &trimmed[heading_level + 1..];
6457 let text = &trimmed[heading_level + 1..];
6289 html.push_str(&format!(
6458 html.push_str(&format!(
6290 "<h{}>{}</h{}>\n",
6459 "<h{}>{}</h{}>\n",
@@ -6295,28 +6464,30 @@ fn markdown_to_html(lines: &[String]) -> String {
6295 continue;
6464 continue;
6467+ // Horizontal rule (checked before list items so `---`/`***` aren't
6468+ // mistaken for bullets)
6469+ if trimmed == "---" || trimmed == "***" || trimmed == "___" {
6470+ flush_para(&mut para_buf, &mut html);
6471+ close_list(&mut list, &mut html);
6472+ html.push_str("<hr>\n");
6298 // Unordered list items
6476 // Unordered list items
6299 if (trimmed.starts_with("- ") || trimmed.starts_with("* ")) && trimmed.len() > 2 {
6477 if (trimmed.starts_with("- ") || trimmed.starts_with("* ")) && trimmed.len() > 2 {
6300 flush_para(&mut para_buf, &mut html);
6478 flush_para(&mut para_buf, &mut html);
6301− if !in_list {
6479+ open_list(&mut list, "ul", &mut html);
6302− html.push_str("<ul>\n");
6305 html.push_str(&format!("<li>{}</li>\n", md_inline(&trimmed[2..])));
6480 html.push_str(&format!("<li>{}</li>\n", md_inline(&trimmed[2..])));
6306 continue;
6481 continue;
6309 // Ordered list items
6484 // Ordered list items
6310 if let Some(dot_pos) = trimmed.find(". ")
6485 if let Some(dot_pos) = trimmed.find(". ")
6311− && dot_pos <= 3
6486+ && (1..=3).contains(&dot_pos)
6312 && trimmed[..dot_pos].chars().all(|c| c.is_ascii_digit())
6487 && trimmed[..dot_pos].chars().all(|c| c.is_ascii_digit())
6314 flush_para(&mut para_buf, &mut html);
6489 flush_para(&mut para_buf, &mut html);
6315− if in_list {
6490+ open_list(&mut list, "ol", &mut html);
6316− html.push_str("</ul>\n");
6319− // We use <ol> but just emit <li> for simplicity
6320 html.push_str(&format!(
6491 html.push_str(&format!(
6321 "<li>{}</li>\n",
6492 "<li>{}</li>\n",
6322 md_inline(&trimmed[dot_pos + 2..])
6493 md_inline(&trimmed[dot_pos + 2..])
@@ -6324,20 +6495,10 @@ fn markdown_to_html(lines: &[String]) -> String {
6324 continue;
6495 continue;
6328− if trimmed == "---" || trimmed == "***" || trimmed == "___" {
6329− flush_para(&mut para_buf, &mut html);
6331− html.push_str("</ul>\n");
6334− html.push_str("<hr>\n");
6338 // Blockquote
6498 // Blockquote
6339 if let Some(quote_content) = trimmed.strip_prefix("> ") {
6499 if let Some(quote_content) = trimmed.strip_prefix("> ") {
6340 flush_para(&mut para_buf, &mut html);
6500 flush_para(&mut para_buf, &mut html);
6501+ close_list(&mut list, &mut html);
6341 html.push_str(&format!(
6502 html.push_str(&format!(
6342 "<blockquote><p>{}</p></blockquote>\n",
6503 "<blockquote><p>{}</p></blockquote>\n",
6343 md_inline(quote_content)
6504 md_inline(quote_content)
@@ -6346,6 +6507,7 @@ fn markdown_to_html(lines: &[String]) -> String {
6348 // Otherwise, accumulate paragraph text
6509 // Otherwise, accumulate paragraph text
6510+ close_list(&mut list, &mut html);
6349 if !para_buf.is_empty() {
6511 if !para_buf.is_empty() {
6350 para_buf.push(' ');
6512 para_buf.push(' ');
@@ -6353,9 +6515,7 @@ fn markdown_to_html(lines: &[String]) -> String {
6355 // Flush remaining
6517 // Flush remaining
6356− if in_list {
6518+ close_list(&mut list, &mut html);
6357− html.push_str("</ul>\n");
6359 flush_para(&mut para_buf, &mut html);
6519 flush_para(&mut para_buf, &mut html);
@@ -6576,4 +6736,15 @@ mod tests {
6576 let r = parse("@let x 1 + 2\n");
6736 let r = parse("@let x 1 + 2\n");
6577 assert_eq!(r.document.variables.get("x").map(|s| s.as_str()), Some("3"));
6737 assert_eq!(r.document.variables.get("x").map(|s| s.as_str()), Some("3"));
6741+ fn decode_chunked_handles_bytes_and_malformed_input() {
6742+ // Chunk sizes are byte counts: "é" is 2 bytes.
6743+ let body = "3\r\naé\r\n2\r\nbc\r\n0\r\n\r\n".as_bytes();
6744+ assert_eq!(decode_chunked(body), "aébc".as_bytes());
6745+ // No CRLF after the size line — must not panic.
6746+ assert_eq!(decode_chunked(b"5"), b"");
6747+ // Declared size larger than the data — take what's there.
6748+ assert_eq!(decode_chunked(b"a\r\nabc"), b"abc");