@@ -260,6 +260,57 @@ fn preprocess(input: &str) -> Vec<Line> {
260260
261261 let indent = line.len() - line.trim_start().len();
262262
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[");
271+ if verbatim_body {
272+ lines.push(Line {
273+ indent,
274+ content: LineContent::Normal(trimmed.to_string()),
275+ line_num: i + 1,
276+ });
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() {
283+ j += 1;
284+ continue;
285+ }
286+ if l.len() - l.trim_start().len() <= indent {
287+ break;
288+ }
289+ j += 1;
290+ body_end = j;
291+ }
292+ let body = &raw_lines[body_start..body_end];
293+ let body_indent = body
294+ .iter()
295+ .filter(|l| !l.trim().is_empty())
296+ .map(|l| l.len() - l.trim_start().len())
297+ .min()
298+ .unwrap_or(0);
299+ if !body.is_empty() {
300+ let text: Vec<&str> = body
301+ .iter()
302+ .map(|l| l.get(body_indent..).unwrap_or("").trim_end())
303+ .collect();
304+ lines.push(Line {
305+ indent: indent + 1,
306+ content: LineContent::Raw(text.join("\n")),
307+ line_num: body_start + 1,
308+ });
309+ }
310+ i = body_end.max(body_start);
311+ continue;
312+ }
313+
263314 // Handle @raw """..."""
264315 if let Some(raw_rest) = trimmed.strip_prefix("@raw") {
265316 let after_raw = raw_rest.trim_start();
@@ -305,9 +356,7 @@ fn preprocess(input: &str) -> Vec<Line> {
305356 // Join continuation lines for multi-line attribute brackets
306357 let first_line_num = i + 1;
307358 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() {
311360 i += 1;
312361 let next = raw_lines[i].trim();
313362 if next.is_empty() || next.starts_with("--") {
@@ -315,8 +364,6 @@ fn preprocess(input: &str) -> Vec<Line> {
315364 }
316365 full.push(' ');
317366 full.push_str(next);
318− depth += next.chars().filter(|&c| c == '[').count() as i32;
319− depth -= next.chars().filter(|&c| c == ']').count() as i32;
320367 }
321368
322369 lines.push(Line {
@@ -330,6 +377,48 @@ fn preprocess(input: &str) -> Vec<Line> {
330377 lines
331378}
332379
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('[') {
386+ return 0;
387+ }
388+ let bytes = line.as_bytes();
389+ let mut depth: i32 = 0;
390+ for (i, &b) in bytes.iter().enumerate() {
391+ match b {
392+ b'[' if depth > 0 => depth += 1,
393+ b'[' => {
394+ // Is this the start of an attribute list?
395+ let before = line[..i].trim_end();
396+ let last_directive = before
397+ .rsplit([']', '>'])
398+ .next()
399+ .unwrap_or("")
400+ .trim();
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('@'),
406+ _ => false,
407+ };
408+ if starts_list {
409+ depth = 1;
410+ } else if before.ends_with(']') {
411+ // Text after a closed attribute list: stop scanning.
412+ return 0;
413+ }
414+ }
415+ b']' if depth > 0 => depth -= 1,
416+ _ => {}
417+ }
418+ }
419+ depth
420+}
421+
333422// ---------------------------------------------------------------------------
334423// Parser
335424// ---------------------------------------------------------------------------
@@ -419,9 +508,16 @@ impl Parser {
419508 if let Some(rest) = content.strip_prefix("@let ") {
420509 let rest = rest.trim();
421510
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)| {
514+ let v = v.trim();
515+ v.strip_prefix("= ").unwrap_or(v).trim_start().starts_with("\"\"\"")
516+ });
422517 // Check if next lines are indented (function/component definition)
423− let has_body =
424− self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent;
518+ let has_body = !opens_triple_quote
519+ && self.pos < self.lines.len()
520+ && self.lines[self.pos].indent > current_indent;
425521
426522 if has_body {
427523 // Function definition: @let name $param1 $param2=default
@@ -485,6 +581,8 @@ impl Parser {
485581
486582 // Multi-line @let with triple quotes: @let name """..."""
487583 let value_str;
584+ let quoted = value.starts_with('"') && value.ends_with('"') && value.len() >= 2
585+ || value.starts_with("\"\"\"");
488586 let value = if let Some(after_open) = value.strip_prefix("\"\"\"") {
489587 if after_open.ends_with("\"\"\"") && after_open.len() >= 3 {
490588 // Single-line triple-quote: @let name """value"""
@@ -525,7 +623,13 @@ impl Parser {
525623 };
526624 track_var_refs(value, &mut ctx.used_variables);
527625 let value = substitute_vars(value, &ctx.variables);
528− let value = evaluate_arithmetic(&value);
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 {
629+ value
630+ } else {
631+ evaluate_arithmetic(&value)
632+ };
529633 if name.starts_with("--") {
530634 // CSS custom property
531635 ctx.css_vars.push((name.to_string(), value.clone()));
@@ -682,7 +786,7 @@ impl Parser {
682786 while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
683787 match &self.lines[self.pos].content {
684788 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)),
686790 }
687791 self.pos += 1;
688792 }
@@ -1636,6 +1740,7 @@ impl Parser {
16361740
16371741 if let Some(rest) = content.strip_prefix("@if ") {
16381742 let rest = rest.trim();
1743+ track_var_refs(rest, &mut ctx.used_variables);
16391744 let condition = substitute_vars(rest, &ctx.variables);
16401745 let result = evaluate_condition(&condition);
16411746
@@ -1658,6 +1763,7 @@ impl Parser {
16581763 let trimmed = s.trim();
16591764 if let Some(else_if_cond) = trimmed.strip_prefix("@else if ") {
16601765 self.pos += 1; // consume @else if
1766+ track_var_refs(else_if_cond, &mut ctx.used_variables);
16611767 let cond = substitute_vars(else_if_cond.trim(), &ctx.variables);
16621768 let cond_result = evaluate_condition(&cond);
16631769 let mut body = Vec::new();
@@ -1722,6 +1828,7 @@ impl Parser {
17221828
17231829 if let Some(rest) = content.strip_prefix("@unless ") {
17241830 let rest = rest.trim();
1831+ track_var_refs(rest, &mut ctx.used_variables);
17251832 let condition = substitute_vars(rest, &ctx.variables);
17261833 let result = !evaluate_condition(&condition);
17271834 let mut body_lines = Vec::new();
@@ -1781,8 +1888,16 @@ impl Parser {
17811888 let var_name = var_names[0].clone();
17821889 let index_var = var_names.get(1).cloned();
17831890
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()
1896+ }
1897+ _ => list_str,
1898+ };
17851899 track_var_refs(&list_str, &mut ctx.used_variables);
1900+ let list_str = substitute_vars(&list_str, &ctx.variables);
17861901 // Support range syntax: @each $i in 1..5 or @each $i in 0..100 step 10
17871902 let items: Vec<String> = if let Some((start_s, rest)) = list_str.split_once("..") {
17881903 let (end_s, step) = if let Some((e, s)) = rest.split_once(" step ") {
@@ -1792,23 +1907,7 @@ impl Parser {
17921907 };
17931908 if let (Ok(start), Ok(end)) = (start_s.trim().parse::<i64>(), end_s.parse::<i64>())
17941909 {
1795− if start <= end {
1796− let mut items = Vec::new();
1797− let mut n = start;
1798− while n <= end {
1799− items.push(n.to_string());
1800− n += step;
1801− }
1802− items
1803− } else {
1804− let mut items = Vec::new();
1805− let mut n = start;
1806− while n >= end {
1807− items.push(n.to_string());
1808− n -= step;
1809− }
1810− items
1811− }
1910+ numeric_range(start, end, step)
18121911 } else {
18131912 list_str
18141913 .split(',')
@@ -1847,11 +1946,13 @@ impl Parser {
18471946 .variables
18481947 .get("_page")
18491948 .and_then(|v| v.parse().ok())
1850− .unwrap_or(1);
1949+ .unwrap_or(1)
1950+ .max(1);
1951+ let size = size.max(1);
18511952 let total_items = items.len();
18521953 let total_pages = total_items.div_ceil(size);
1853− let start = (current_page - 1) * size;
1854− let end = (start + size).min(total_items);
1954+ let start = (current_page - 1).saturating_mul(size);
1955+ let end = start.saturating_add(size).min(total_items);
18551956 let page_items: Vec<String> = if start < total_items {
18561957 items[start..end].to_vec()
18571958 } else {
@@ -1909,7 +2010,9 @@ impl Parser {
19092010 lines: adjusted,
19102011 pos: 0,
19112012 };
2013+ let saved_vars = ctx.variables.clone();
19122014 let nodes = body_parser.parse_children(0, ctx);
2015+ ctx.variables = saved_vars;
19132016 return Ok(Some(nodes));
19142017 }
19152018
@@ -1986,8 +2089,8 @@ impl Parser {
19862089 message: "@for requires: @for $var in start..end".to_string(),
19872090 });
19882091 };
1989− let range_str = substitute_vars(&range_str, &ctx.variables);
19902092 track_var_refs(&range_str, &mut ctx.used_variables);
2093+ let range_str = substitute_vars(&range_str, &ctx.variables);
19912094
19922095 let items: Vec<String> = if let Some((start_s, rest_range)) = range_str.split_once("..")
19932096 {
@@ -1998,23 +2101,7 @@ impl Parser {
19982101 };
19992102 if let (Ok(start), Ok(end)) = (start_s.trim().parse::<i64>(), end_s.parse::<i64>())
20002103 {
2001− if start <= end {
2002− let mut items = Vec::new();
2003− let mut n = start;
2004− while n <= end {
2005− items.push(n.to_string());
2006− n += step;
2007− }
2008− items
2009− } else {
2010− let mut items = Vec::new();
2011− let mut n = start;
2012− while n >= end {
2013− items.push(n.to_string());
2014− n -= step;
2015− }
2016− items
2017− }
2104+ numeric_range(start, end, step)
20182105 } else {
20192106 return Err(ParseError {
20202107 line: line_num,
@@ -2920,8 +3007,10 @@ impl Parser {
29203007
29213008 // --- Bare text ---
29223009
2923− let var_warnings = check_undefined_vars(&content, &ctx.variables, line_num);
3010+ let var_warnings =
3011+ check_undefined_vars(&content, &ctx.variables, line_num, current_indent);
29243012 ctx.diagnostics.extend(var_warnings);
3013+ track_var_refs(&content, &mut ctx.used_variables);
29253014 let segments = parse_text_segments(&content, ctx);
29263015 Ok(Some(vec![Node::Text(segments)]))
29273016 }
@@ -2997,7 +3086,13 @@ impl Parser {
29973086 .iter()
29983087 .find(|a| a.key == *param)
29993088 .and_then(|a| a.value.clone())
3000− .or_else(|| args.get(i).and_then(|a| a.value.clone()))
3089+ .or_else(|| {
3090+ // Positional fallback, unless that argument is named for
3091+ // a different parameter.
3092+ args.get(i)
3093+ .filter(|a| !fn_def.params.contains(&a.key))
3094+ .and_then(|a| a.value.clone())
3095+ })
30013096 .or_else(|| fn_def.defaults.get(param).cloned())
30023097 .unwrap_or_default();
30033098 ctx.variables.insert(param.clone(), value);
@@ -3196,6 +3291,7 @@ fn parse_single_element(
31963291 };
31973292
31983293 let rest = rest.trim().to_string();
3294+ track_var_refs(&rest, &mut ctx.used_variables);
31993295
32003296 // For @link, first token of rest is URL, remainder is inline text
32013297 let mut children = Vec::new();
@@ -3588,10 +3684,13 @@ fn suggest_var_name(input: &str, vars: &HashMap<String, String>) -> Option<Strin
35883684}
35893685
35903686/// 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.
35913689fn check_undefined_vars(
35923690 input: &str,
35933691 vars: &HashMap<String, String>,
35943692 line_num: usize,
3693+ indent: usize,
35953694) -> Vec<Diagnostic> {
35963695 let mut warnings = Vec::new();
35973696 if !input.contains('$') {
@@ -3625,13 +3724,13 @@ fn check_undefined_vars(
36253724 {
36263725 warnings.push(Diagnostic {
36273726 line: line_num,
3628− column: Some(col),
3727+ column: Some(indent + col),
36293728 message: format!(
36303729 "undefined variable '${}', did you mean '${}'?",
36313730 name, closest
36323731 ),
36333732 severity: Severity::Warning,
3634− source_line: Some(input.to_string()),
3733+ source_line: Some(format!("{}{}", " ".repeat(indent), input)),
36353734 });
36363735 }
36373736 i = end;
@@ -4006,8 +4105,8 @@ const NUMERIC_ATTRS: &[&str] = &[
40064105];
40074106
40084107const CSS_UNIT_SUFFIXES: &[&str] = &[
4009− "%", "rem", "em", "vh", "vw", "vmin", "vmax", "dvh", "svh", "lvh", "ch", "ex", "cm", "mm",
4010− "in", "pt", "pc", "fr",
4108+ "px", "%", "rem", "em", "vh", "vw", "vmin", "vmax", "dvh", "svh", "lvh", "ch", "ex", "cm",
4109+ "mm", "in", "pt", "pc", "fr",
40114110];
40124111
40134112fn has_css_unit(value: &str) -> bool {
@@ -4559,9 +4658,12 @@ fn split_commas(input: &str) -> Vec<&str> {
45594658 let mut parts = Vec::new();
45604659 let mut start = 0;
45614660 let mut depth = 0;
4661+ let mut in_quotes = false;
45624662
45634663 for (i, c) in input.char_indices() {
45644664 match c {
4665+ '"' => in_quotes = !in_quotes,
4666+ _ if in_quotes => {}
45654667 '[' | '(' => depth += 1,
45664668 ']' | ')' => depth -= 1,
45674669 ',' if depth == 0 => {
@@ -4693,6 +4795,31 @@ fn parse_text_segments(input: &str, ctx: &mut ParseContext) -> Vec<TextSegment>
46934795// Condition evaluation for @if
46944796// ---------------------------------------------------------------------------
46954797
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();
4802+ let mut n = start;
4803+ if start <= end {
4804+ while n <= end {
4805+ items.push(n.to_string());
4806+ match n.checked_add(step) {
4807+ Some(next) => n = next,
4808+ None => break,
4809+ }
4810+ }
4811+ } else {
4812+ while n >= end {
4813+ items.push(n.to_string());
4814+ match n.checked_sub(step) {
4815+ Some(next) => n = next,
4816+ None => break,
4817+ }
4818+ }
4819+ }
4820+ items
4821+}
4822+
46964823fn evaluate_condition(condition: &str) -> bool {
46974824 // Ternary expression support: condition ? true_val : false_val
46984825 // (just check the condition part, the ternary is handled in substitute_ternary)
@@ -5381,6 +5508,9 @@ fn validate_tree(
53815508
53825509fn parse_hex_rgb(s: &str) -> Option<(u8, u8, u8)> {
53835510 let s = s.strip_prefix('#')?;
5511+ if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
5512+ return None;
5513+ }
53845514 match s.len() {
53855515 3 => {
53865516 let r = u8::from_str_radix(&s[0..1], 16).ok()?;
@@ -5535,7 +5665,7 @@ fn parse_keyframe_line(line: &str) -> Option<String> {
55355665 let inner = &rest[1..rest.len() - 1];
55365666 // Parse comma-separated key-value pairs into CSS
55375667 let mut css = String::new();
5538− for part in inner.split(',') {
5668+ for part in split_commas(inner) {
55395669 let part = part.trim();
55405670 if part.is_empty() {
55415671 continue;
@@ -5584,9 +5714,9 @@ fn replace_extends_slots(nodes: Vec<Node>, slot_nodes: &HashMap<String, Vec<Node
55845714fn apply_filter(value: &str, filter: &str) -> String {
55855715 if let Some(arg) = filter.strip_prefix("truncate:") {
55865716 if let Ok(n) = arg.parse::<usize>()
5587− && value.len() > n
5717+ && value.chars().count() > n
55885718 {
5589− return format!("{}...", &value[..n]);
5719+ return format!("{}...", value.chars().take(n).collect::<String>());
55905720 }
55915721 return value.to_string();
55925722 }
@@ -5656,7 +5786,7 @@ fn apply_filter(value: &str, filter: &str) -> String {
56565786 }
56575787 }
56585788 "trim" => value.trim().to_string(),
5659− "length" | "len" => value.len().to_string(),
5789+ "length" | "len" => value.chars().count().to_string(),
56605790 "reverse" => value.chars().rev().collect(),
56615791 _ => value.to_string(),
56625792 }
@@ -5896,7 +6026,7 @@ fn parse_json_value(chars: &[char], mut pos: usize) -> Option<(JsonValue, usize)
58966026}
58976027
58986028fn parse_json_string(chars: &[char], mut pos: usize) -> Option<(String, usize)> {
5899− if chars[pos] != '"' {
6029+ if chars.get(pos) != Some(&'"') {
59006030 return None;
59016031 }
59026032 pos += 1;
@@ -5909,6 +6039,28 @@ fn parse_json_string(chars: &[char], mut pos: usize) -> Option<(String, usize)>
59096039 'n' => s.push('\n'),
59106040 't' => s.push('\t'),
59116041 'r' => s.push('\r'),
6042+ 'b' => s.push('\u{8}'),
6043+ 'f' => s.push('\u{c}'),
6044+ 'u' => {
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()
6048+ };
6049+ let hi = hex4(pos + 1)?;
6050+ pos += 4;
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)
6056+ {
6057+ pos += 6;
6058+ 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00)
6059+ } else {
6060+ hi
6061+ };
6062+ s.push(char::from_u32(code).unwrap_or('\u{FFFD}'));
6063+ }
59126064 _ => {
59136065 s.push('\\');
59146066 s.push(chars[pos]);
@@ -6115,11 +6267,10 @@ fn fetch_url_blocking(url: &str) -> Result<String, String> {
61156267 .read_to_end(&mut response)
61166268 .map_err(|e| format!("read failed: {}", e))?;
61176269
6118− let response_str = String::from_utf8_lossy(&response);
6119− // Split headers and body
6120− if let Some(body_start) = response_str.find("\r\n\r\n") {
6121− let headers = &response_str[..body_start];
6122− let body = &response_str[body_start + 4..];
6270+ // Split headers and body (on bytes, so chunk sizes stay byte counts)
6271+ if let Some(body_start) = response.windows(4).position(|w| w == b"\r\n\r\n") {
6272+ let headers = String::from_utf8_lossy(&response[..body_start]);
6273+ let body = &response[body_start + 4..];
61236274
61246275 // Check status code
61256276 if let Some(first_line) = headers.lines().next()
@@ -6136,40 +6287,33 @@ fn fetch_url_blocking(url: &str) -> Result<String, String> {
61366287 .to_lowercase()
61376288 .contains("transfer-encoding: chunked")
61386289 {
6139− return Ok(decode_chunked(body));
6290+ return Ok(String::from_utf8_lossy(&decode_chunked(body)).into_owned());
61406291 }
61416292
6142− Ok(body.to_string())
6293+ Ok(String::from_utf8_lossy(body).into_owned())
61436294 } else {
61446295 Err("malformed HTTP response".to_string())
61456296 }
61466297}
61476298
6148−fn decode_chunked(body: &str) -> String {
6149− let mut result = String::new();
6299+fn decode_chunked(body: &[u8]) -> Vec<u8> {
6300+ let mut result = Vec::new();
61506301 let mut rest = body;
6151− loop {
6152− let rest_trimmed = rest.trim_start();
6153− if rest_trimmed.is_empty() {
6154− break;
6155− }
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);
6302+ while let Some(size_end) = rest.windows(2).position(|w| w == b"\r\n") {
6303+ let size_line = String::from_utf8_lossy(&rest[..size_end]);
6304+ // Chunk extensions (`;name=value`) may follow the size.
6305+ let size_str = size_line.split(';').next().unwrap_or("").trim();
6306+ let size = usize::from_str_radix(size_str, 16).unwrap_or(0);
61596307 if size == 0 {
61606308 break;
61616309 }
6162− let chunk_start = size_end + 2;
6163− if chunk_start + size <= rest_trimmed.len() {
6164− result.push_str(&rest_trimmed[chunk_start..chunk_start + size]);
6165− rest = &rest_trimmed[chunk_start + size..];
6166− if rest.starts_with("\r\n") {
6167− rest = &rest[2..];
6168− }
6169− } else {
6170− result.push_str(&rest_trimmed[chunk_start..]);
6310+ let chunk = &rest[size_end + 2..];
6311+ if size > chunk.len() {
6312+ result.extend_from_slice(chunk);
61716313 break;
61726314 }
6315+ result.extend_from_slice(&chunk[..size]);
6316+ rest = chunk[size..].strip_prefix(b"\r\n").unwrap_or(&chunk[size..]);
61736317 }
61746318 result
61756319}
@@ -6179,35 +6323,52 @@ fn decode_chunked(body: &str) -> String {
61796323// ---------------------------------------------------------------------------
61806324
61816325fn 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();
6330+ };
6331+ let tag_end = svg[tag_start..]
6332+ .find('>')
6333+ .map(|p| tag_start + p)
6334+ .unwrap_or(svg.len());
6335+ let tag = &svg[tag_start..tag_end];
6336+
6337+ // Replace an existing attribute (must be preceded by whitespace so that
6338+ // `width` doesn't match `stroke-width`).
61836339 let pattern = format!("{}=\"", attr_name);
6184− if let Some(pos) = svg.find(&pattern) {
6185− let after = &svg[pos + pattern.len()..];
6186− if let Some(end) = after.find('"') {
6340+ let existing = tag.match_indices(&pattern).find(|(pos, _)| {
6341+ tag[..*pos]
6342+ .chars()
6343+ .next_back()
6344+ .is_some_and(char::is_whitespace)
6345+ });
6346+ if let Some((pos, _)) = existing {
6347+ let value_start = tag_start + pos + pattern.len();
6348+ if let Some(end) = svg[value_start..tag_end].find('"') {
61876349 let mut result = String::with_capacity(svg.len());
6188− result.push_str(&svg[..pos]);
6189− result.push_str(attr_name);
6190− result.push_str("=\"");
6350+ result.push_str(&svg[..value_start]);
61916351 result.push_str(value);
6192− result.push('"');
6193− result.push_str(&after[end + 1..]);
6352+ result.push_str(&svg[value_start + end..]);
61946353 return result;
61956354 }
61966355 }
6197− // Otherwise inject it into the opening <svg tag
6198− if let Some(pos) = svg.find("<svg") {
6199− let tag_end = svg[pos..].find('>').map(|p| pos + p).unwrap_or(svg.len());
6200− let mut result = String::with_capacity(svg.len() + attr_name.len() + value.len() + 4);
6201− result.push_str(&svg[..tag_end]);
6202− result.push(' ');
6203− result.push_str(attr_name);
6204− result.push_str("=\"");
6205− result.push_str(value);
6206− result.push('"');
6207− result.push_str(&svg[tag_end..]);
6208− return result;
6209− }
6210− svg.to_string()
6356+
6357+ // Otherwise inject it into the opening tag (before a self-closing `/`).
6358+ let insert_at = if svg[..tag_end].ends_with('/') {
6359+ tag_end - 1
6360+ } else {
6361+ tag_end
6362+ };
6363+ let mut result = String::with_capacity(svg.len() + attr_name.len() + value.len() + 4);
6364+ result.push_str(svg[..insert_at].trim_end());
6365+ result.push(' ');
6366+ result.push_str(attr_name);
6367+ result.push_str("=\"");
6368+ result.push_str(value);
6369+ result.push('"');
6370+ result.push_str(&svg[insert_at..]);
6371+ result
62116372}
62126373
62136374// ---------------------------------------------------------------------------
@@ -6216,7 +6377,8 @@ fn set_svg_attr(svg: &str, attr_name: &str, value: &str) -> String {
62166377
62176378fn markdown_to_html(lines: &[String]) -> String {
62186379 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;
62206382 let mut in_code_block = false;
62216383 let mut code_lang = String::new();
62226384 let mut code_buf = String::new();
@@ -6231,6 +6393,20 @@ fn markdown_to_html(lines: &[String]) -> String {
62316393 }
62326394 para.clear();
62336395 };
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));
6399+ }
6400+ };
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));
6405+ }
6406+ out.push_str(&format!("<{}>\n", tag));
6407+ *list = Some(tag);
6408+ }
6409+ };
62346410
62356411 for line in lines {
62366412 let trimmed = line.trim();
@@ -6250,6 +6426,7 @@ fn markdown_to_html(lines: &[String]) -> String {
62506426 in_code_block = false;
62516427 } else {
62526428 flush_para(&mut para_buf, &mut html);
6429+ close_list(&mut list, &mut html);
62536430 code_lang = after_fence.trim().to_string();
62546431 in_code_block = true;
62556432 }
@@ -6259,32 +6436,24 @@ fn markdown_to_html(lines: &[String]) -> String {
62596436 if !code_buf.is_empty() {
62606437 code_buf.push('\n');
62616438 }
6262− code_buf.push_str(trimmed);
6439+ // Keep indentation inside code blocks.
6440+ code_buf.push_str(line.trim_end());
62636441 continue;
62646442 }
62656443
62666444 // Blank line ends paragraph
62676445 if trimmed.is_empty() {
62686446 flush_para(&mut para_buf, &mut html);
6269− if in_list {
6270− html.push_str("</ul>\n");
6271− in_list = false;
6272− }
6447+ close_list(&mut list, &mut html);
62736448 continue;
62746449 }
62756450
62766451 // Headings
6277− if trimmed.starts_with("###### /* ") {
6278− // skip
6279− }
62806452 let heading_level = trimmed.bytes().take_while(|&b| b == b'#').count();
62816453 if (1..=6).contains(&heading_level) && trimmed.as_bytes().get(heading_level) == Some(&b' ')
62826454 {
62836455 flush_para(&mut para_buf, &mut html);
6284− if in_list {
6285− html.push_str("</ul>\n");
6286− in_list = false;
6287− }
6456+ close_list(&mut list, &mut html);
62886457 let text = &trimmed[heading_level + 1..];
62896458 html.push_str(&format!(
62906459 "<h{}>{}</h{}>\n",
@@ -6295,28 +6464,30 @@ fn markdown_to_html(lines: &[String]) -> String {
62956464 continue;
62966465 }
62976466
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");
6473+ continue;
6474+ }
6475+
62986476 // Unordered list items
62996477 if (trimmed.starts_with("- ") || trimmed.starts_with("* ")) && trimmed.len() > 2 {
63006478 flush_para(&mut para_buf, &mut html);
6301− if !in_list {
6302− html.push_str("<ul>\n");
6303− in_list = true;
6304− }
6479+ open_list(&mut list, "ul", &mut html);
63056480 html.push_str(&format!("<li>{}</li>\n", md_inline(&trimmed[2..])));
63066481 continue;
63076482 }
63086483
63096484 // Ordered list items
63106485 if let Some(dot_pos) = trimmed.find(". ")
6311− && dot_pos <= 3
6486+ && (1..=3).contains(&dot_pos)
63126487 && trimmed[..dot_pos].chars().all(|c| c.is_ascii_digit())
63136488 {
63146489 flush_para(&mut para_buf, &mut html);
6315− if in_list {
6316− html.push_str("</ul>\n");
6317− in_list = false;
6318− }
6319− // We use <ol> but just emit <li> for simplicity
6490+ open_list(&mut list, "ol", &mut html);
63206491 html.push_str(&format!(
63216492 "<li>{}</li>\n",
63226493 md_inline(&trimmed[dot_pos + 2..])
@@ -6324,20 +6495,10 @@ fn markdown_to_html(lines: &[String]) -> String {
63246495 continue;
63256496 }
63266497
6327− // Horizontal rule
6328− if trimmed == "---" || trimmed == "***" || trimmed == "___" {
6329− flush_para(&mut para_buf, &mut html);
6330− if in_list {
6331− html.push_str("</ul>\n");
6332− in_list = false;
6333− }
6334− html.push_str("<hr>\n");
6335− continue;
6336− }
6337−
63386498 // Blockquote
63396499 if let Some(quote_content) = trimmed.strip_prefix("> ") {
63406500 flush_para(&mut para_buf, &mut html);
6501+ close_list(&mut list, &mut html);
63416502 html.push_str(&format!(
63426503 "<blockquote><p>{}</p></blockquote>\n",
63436504 md_inline(quote_content)
@@ -6346,6 +6507,7 @@ fn markdown_to_html(lines: &[String]) -> String {
63466507 }
63476508
63486509 // Otherwise, accumulate paragraph text
6510+ close_list(&mut list, &mut html);
63496511 if !para_buf.is_empty() {
63506512 para_buf.push(' ');
63516513 }
@@ -6353,9 +6515,7 @@ fn markdown_to_html(lines: &[String]) -> String {
63536515 }
63546516
63556517 // Flush remaining
6356− if in_list {
6357− html.push_str("</ul>\n");
6358− }
6518+ close_list(&mut list, &mut html);
63596519 flush_para(&mut para_buf, &mut html);
63606520
63616521 html
@@ -6576,4 +6736,15 @@ mod tests {
65766736 let r = parse("@let x 1 + 2\n");
65776737 assert_eq!(r.document.variables.get("x").map(|s| s.as_str()), Some("3"));
65786738 }
6739+
6740+ #[test]
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");
6749+ }
65796750}