Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 39 additions & 13 deletions crates/biome_markdown_parser/src/syntax/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -858,6 +858,12 @@ fn is_quote_blank_line_after_newline(p: &mut MarkdownParser, quote_depth: usize)

p.lookahead(|p| {
p.bump(NEWLINE);
is_quote_blank_line_from_current(p, quote_depth)
})
}

fn is_quote_blank_line_from_current(p: &mut MarkdownParser, quote_depth: usize) -> bool {
p.lookahead(|p| {
if is_quote_only_blank_line_from_source(p, quote_depth) {
return true;
}
Expand Down Expand Up @@ -1009,8 +1015,22 @@ fn handle_inline_newline(p: &mut MarkdownParser, has_content: bool) -> InlineNew
// Consume the NEWLINE as textual content (remap to MD_TEXTUAL_LITERAL)
emit_inline_newline_as_text(p);

// If we're inside a block quote, only consume the quote prefix
// when it doesn't start a new block (e.g., a nested quote).
handle_line_continuation(p, has_content, true)
}

/// Consume container continuation prefixes and check for block interrupts.
///
/// Called after a line boundary has been crossed — either a NEWLINE (via
/// `handle_inline_newline`) or a hard line break (`MD_HARD_LINE_LITERAL`,
/// which embeds its own newline). Handles quote prefixes, list indent,
/// setext underlines, and other block-level constructs that can end or
/// interrupt a paragraph.
fn handle_line_continuation(
p: &mut MarkdownParser,
has_content: bool,
emit_indent_tokens: bool,
) -> InlineNewlineAction {
let quote_depth = p.state().block_quote_depth;
if break_for_quote_prefix_after_inline_newline(p, quote_depth) {
return InlineNewlineAction::Break;
}
Expand All @@ -1024,12 +1044,6 @@ fn handle_inline_newline(p: &mut MarkdownParser, has_content: bool) -> InlineNew
return InlineNewlineAction::Break;
}

// Inside a nested list item (depth >= 2), break the paragraph when the
// continuation line's indent drops to or below the marker column.
// Such lines belong to a parent item, not lazy continuation.
// We only check nesting depth, not marker_indent alone, because a
// top-level list with leading whitespace (e.g. ` 1. text`) still
// allows lazy continuation at indent 0 per CommonMark §5.2.
if required_indent > 0 && p.state().list_nesting_depth >= 2 {
let marker_indent = p.state().list_item_marker_indent;
if marker_indent > 0 {
Expand All @@ -1040,9 +1054,6 @@ fn handle_inline_newline(p: &mut MarkdownParser, has_content: bool) -> InlineNew
}
}

// Check for block-level constructs that can interrupt paragraphs.
// Textual fence tokens (e.g. "```") may not be caught by line_starts_with_fence
// because the lexer emits them as MD_TEXTUAL_LITERAL in inline context.
if p.at(MD_TEXTUAL_LITERAL) {
let text = p.cur_text();
if text.starts_with("```") || text.starts_with("~~~") {
Expand All @@ -1053,7 +1064,9 @@ fn handle_inline_newline(p: &mut MarkdownParser, has_content: bool) -> InlineNew
return InlineNewlineAction::Break;
}

emit_inline_continuation_indent(p, required_indent);
if emit_indent_tokens {
emit_inline_continuation_indent(p, required_indent);
}

InlineNewlineAction::Continue
}
Expand Down Expand Up @@ -1145,8 +1158,21 @@ pub(crate) fn parse_inline_item_list(p: &mut MarkdownParser) {
after_hard_break = matches!(&parsed, Present(cm) if cm.kind(p) == MD_HARD_LINE);

// Per CommonMark §6.7: after a hard line break, leading spaces on the
// next line are ignored. Consume as whitespace trivia (structural).
// next line are ignored. First, run container continuation handling so
// block quote/list prefixes remain part of the same paragraph.
if after_hard_break {
let quote_depth = p.state().block_quote_depth;
if p.at(NEWLINE) || is_quote_blank_line_from_current(p, quote_depth) {
break;
}

if matches!(
handle_line_continuation(p, has_content, false),
InlineNewlineAction::Break
) {
break;
}

while p.at(MD_TEXTUAL_LITERAL) && p.cur_text().chars().all(|c| c == ' ' || c == '\t') {
p.consume_as_whitespace_trivia();
}
Expand Down
30 changes: 30 additions & 0 deletions crates/biome_markdown_parser/src/to_html.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2007,6 +2007,36 @@ mod tests {
assert_eq!(html, "<p>foo\\</p>\n");
}

#[test]
fn test_hard_break_in_blockquote() {
let parsed = parse_markdown("> foo \n> bar \n>\n> baz");
let html = document_to_html(
&parsed.tree(),
parsed.list_tightness(),
parsed.list_item_indents(),
parsed.quote_indents(),
);
assert_eq!(
html,
"<blockquote>\n<p>foo<br />\nbar</p>\n<p>baz</p>\n</blockquote>\n"
);
}

#[test]
fn test_hard_break_nested_quote_in_list() {
let parsed = parse_markdown("- > quoted \n > line \n >\n > next para\n\n- after\n");
let html = document_to_html(
&parsed.tree(),
parsed.list_tightness(),
parsed.list_item_indents(),
parsed.quote_indents(),
);
assert_eq!(
html,
"<ul>\n<li>\n<blockquote>\n<p>quoted<br />\nline</p>\n<p>next para</p>\n</blockquote>\n</li>\n<li>\n<p>after</p>\n</li>\n</ul>\n"
);
}

#[test]
fn test_tight_list_marker_split() {
// Two tight lists separated by blank line with different markers
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
> foo
> bar
>
> baz
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
---
source: crates/biome_markdown_parser/tests/spec_test.rs
expression: snapshot
---

## Input

```
> foo
> bar
>
> baz
```


## AST

```
MdDocument {
bom_token: missing (optional),
value: MdBlockList [
MdQuote {
prefix: MdQuotePrefix {
pre_marker_indent: MdQuoteIndentList [],
marker_token: R_ANGLE@0..1 ">" [] [],
post_marker_space_token: MD_QUOTE_POST_MARKER_SPACE@1..2 " " [] [],
},
content: MdBlockList [
MdParagraph {
list: MdInlineItemList [
MdTextual {
value_token: MD_TEXTUAL_LITERAL@2..5 "foo" [] [],
},
MdHardLine {
value_token: MD_HARD_LINE_LITERAL@5..8 " \n" [] [],
},
MdQuotePrefix {
pre_marker_indent: MdQuoteIndentList [],
marker_token: R_ANGLE@8..9 ">" [] [],
post_marker_space_token: MD_QUOTE_POST_MARKER_SPACE@9..10 " " [] [],
},
MdTextual {
value_token: MD_TEXTUAL_LITERAL@10..13 "bar" [] [],
},
MdHardLine {
value_token: MD_HARD_LINE_LITERAL@13..16 " \n" [] [],
},
],
hard_line: missing (optional),
},
MdQuotePrefix {
pre_marker_indent: MdQuoteIndentList [],
marker_token: R_ANGLE@16..17 ">" [] [],
post_marker_space_token: missing (optional),
},
MdNewline {
value_token: NEWLINE@17..18 "\n" [] [],
},
MdQuotePrefix {
pre_marker_indent: MdQuoteIndentList [],
marker_token: R_ANGLE@18..19 ">" [] [],
post_marker_space_token: MD_QUOTE_POST_MARKER_SPACE@19..20 " " [] [],
},
MdParagraph {
list: MdInlineItemList [
MdTextual {
value_token: MD_TEXTUAL_LITERAL@20..23 "baz" [] [],
},
],
hard_line: missing (optional),
},
],
},
],
eof_token: EOF@23..23 "" [] [],
}
```

## CST

```
0: MD_DOCUMENT@0..23
0: (empty)
1: MD_BLOCK_LIST@0..23
0: MD_QUOTE@0..23
0: MD_QUOTE_PREFIX@0..2
0: MD_QUOTE_INDENT_LIST@0..0
1: R_ANGLE@0..1 ">" [] []
2: MD_QUOTE_POST_MARKER_SPACE@1..2 " " [] []
1: MD_BLOCK_LIST@2..23
0: MD_PARAGRAPH@2..16
0: MD_INLINE_ITEM_LIST@2..16
0: MD_TEXTUAL@2..5
0: MD_TEXTUAL_LITERAL@2..5 "foo" [] []
1: MD_HARD_LINE@5..8
0: MD_HARD_LINE_LITERAL@5..8 " \n" [] []
2: MD_QUOTE_PREFIX@8..10
0: MD_QUOTE_INDENT_LIST@8..8
1: R_ANGLE@8..9 ">" [] []
2: MD_QUOTE_POST_MARKER_SPACE@9..10 " " [] []
3: MD_TEXTUAL@10..13
0: MD_TEXTUAL_LITERAL@10..13 "bar" [] []
4: MD_HARD_LINE@13..16
0: MD_HARD_LINE_LITERAL@13..16 " \n" [] []
1: (empty)
1: MD_QUOTE_PREFIX@16..17
0: MD_QUOTE_INDENT_LIST@16..16
1: R_ANGLE@16..17 ">" [] []
2: (empty)
2: MD_NEWLINE@17..18
0: NEWLINE@17..18 "\n" [] []
3: MD_QUOTE_PREFIX@18..20
0: MD_QUOTE_INDENT_LIST@18..18
1: R_ANGLE@18..19 ">" [] []
2: MD_QUOTE_POST_MARKER_SPACE@19..20 " " [] []
4: MD_PARAGRAPH@20..23
0: MD_INLINE_ITEM_LIST@20..23
0: MD_TEXTUAL@20..23
0: MD_TEXTUAL_LITERAL@20..23 "baz" [] []
1: (empty)
2: EOF@23..23 "" [] []

```
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
- foo
bar

baz

- simple
Loading
Loading