fix(signal): outbound formatting and markdown IR rendering improvements (#9781)

* fix: Signal and markdown formatting improvements

Markdown IR fixes:
- Fix list-paragraph spacing (extra newline between list items and following paragraphs)
- Fix nested list indentation and newline handling
- Fix blockquote_close emitting redundant newline (inner content handles spacing)
- Render horizontal rules as visible ─── separator instead of silent drop
- Strip inner cell styles in code-mode tables to prevent overlapping with code_block span

Signal formatting fixes:
- Normalize URLs for dedup comparison (strip protocol, www., trailing slash)
- Render headings as bold text (headingStyle: 'bold')
- Add '> ' prefix to blockquotes for visual distinction
- Re-chunk after link expansion to respect chunk size limits

Tests:
- 51 new tests for markdown IR (spacing, lists, blockquotes, tables, HR)
- 18 new tests for Signal formatting (URL dedup, headings, blockquotes, HR, chunking)
- Update Slack nested list test expectation to match corrected IR output

* refactor: style-aware Signal text chunker

Replace indexOf-based chunk position tracking with deterministic
cursor tracking. The new splitSignalFormattedText:

- Splits at whitespace/newline boundaries within the limit
- Avoids breaking inside parentheses (preserves expanded link URLs)
- Slices style ranges at chunk boundaries with correct local offsets
- Tracks position via offset arithmetic instead of fragile indexOf

Removes dependency on chunkText from auto-reply/chunk.

Tests: 19 new tests covering style preservation across chunk boundaries,
edge cases (empty text, under limit, exact split points), and integration
with link expansion.

* fix: correct Signal style offsets with multiple link expansions

applyInsertionsToStyles() was using original coordinates for each
insertion without tracking cumulative shift from prior insertions.
This caused bold/italic/etc styles to drift to wrong text positions
when multiple markdown links expanded in a single message.

Added cumulative shift tracking and a regression test.

* test: clean up test noise and fix ineffective assertions

- Remove console.log from ir.list-spacing and ir.hr-spacing tests
- Fix ir.nested-lists.test.ts: remove ineffective regex assertion
- Fix ir.hr-spacing.test.ts: add actual assertions to edge case test

* refactor: split Signal formatting tests (#9781) (thanks @heyhudson)

---------

Co-authored-by: Hudson <258693705+hudson-rivera@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Hudson
2026-02-14 10:57:20 -05:00
committed by GitHub
parent 226bf74634
commit 1d6abddb9f
12 changed files with 1530 additions and 24 deletions
+202
View File
@@ -0,0 +1,202 @@
/**
* Blockquote Spacing Tests
*
* Per CommonMark spec (§5.1 Block quotes), blockquotes are "container blocks" that
* contain other block-level elements (paragraphs, code blocks, etc.).
*
* In plaintext rendering, the expected spacing between block-level elements is
* a single blank line (double newline `\n\n`). This is the standard paragraph
* separation used throughout markdown.
*
* CORRECT behavior:
* - Blockquote content followed by paragraph: "quote\n\nparagraph" (double \n)
* - Two consecutive blockquotes: "first\n\nsecond" (double \n)
*
* BUG (current behavior):
* - Produces triple newlines: "quote\n\n\nparagraph"
*
* Root cause:
* 1. `paragraph_close` inside blockquote adds `\n\n` (correct)
* 2. `blockquote_close` adds another `\n` (incorrect)
* 3. Result: `\n\n\n` (triple newlines - incorrect)
*
* The fix: `blockquote_close` should NOT add `\n` because:
* - Blockquotes are container blocks, not leaf blocks
* - The inner content (paragraph, heading, etc.) already provides block separation
* - Container closings shouldn't add their own spacing
*/
import { describe, it, expect } from "vitest";
import { markdownToIR } from "./ir.js";
describe("blockquote spacing", () => {
describe("blockquote followed by paragraph", () => {
it("should have double newline (one blank line) between blockquote and paragraph", () => {
const input = "> quote\n\nparagraph";
const result = markdownToIR(input);
// CORRECT: "quote\n\nparagraph" (double newline)
// BUG: "quote\n\n\nparagraph" (triple newline)
expect(result.text).toBe("quote\n\nparagraph");
});
it("should not produce triple newlines", () => {
const input = "> quote\n\nparagraph";
const result = markdownToIR(input);
expect(result.text).not.toContain("\n\n\n");
});
});
describe("consecutive blockquotes", () => {
it("should have double newline between two blockquotes", () => {
const input = "> first\n\n> second";
const result = markdownToIR(input);
expect(result.text).toBe("first\n\nsecond");
});
it("should not produce triple newlines between blockquotes", () => {
const input = "> first\n\n> second";
const result = markdownToIR(input);
expect(result.text).not.toContain("\n\n\n");
});
});
describe("nested blockquotes", () => {
it("should handle nested blockquotes correctly", () => {
const input = "> outer\n>> inner";
const result = markdownToIR(input);
// Inner blockquote becomes separate paragraph
expect(result.text).toBe("outer\n\ninner");
});
it("should not produce triple newlines in nested blockquotes", () => {
const input = "> outer\n>> inner\n\nparagraph";
const result = markdownToIR(input);
expect(result.text).not.toContain("\n\n\n");
});
it("should handle deeply nested blockquotes", () => {
const input = "> level 1\n>> level 2\n>>> level 3";
const result = markdownToIR(input);
// Each nested level is a new paragraph
expect(result.text).not.toContain("\n\n\n");
});
});
describe("blockquote followed by other block elements", () => {
it("should have double newline between blockquote and heading", () => {
const input = "> quote\n\n# Heading";
const result = markdownToIR(input);
expect(result.text).toBe("quote\n\nHeading");
expect(result.text).not.toContain("\n\n\n");
});
it("should have double newline between blockquote and list", () => {
const input = "> quote\n\n- item";
const result = markdownToIR(input);
// The list item becomes "• item"
expect(result.text).toBe("quote\n\n• item");
expect(result.text).not.toContain("\n\n\n");
});
it("should have double newline between blockquote and code block", () => {
const input = "> quote\n\n```\ncode\n```";
const result = markdownToIR(input);
// Code blocks preserve their trailing newline
expect(result.text.startsWith("quote\n\ncode")).toBe(true);
expect(result.text).not.toContain("\n\n\n");
});
it("should have double newline between blockquote and horizontal rule", () => {
const input = "> quote\n\n---\n\nparagraph";
const result = markdownToIR(input);
// HR just adds a newline in IR, but should not create triple newlines
expect(result.text).not.toContain("\n\n\n");
});
});
describe("blockquote with multi-paragraph content", () => {
it("should handle multi-paragraph blockquote followed by paragraph", () => {
const input = "> first paragraph\n>\n> second paragraph\n\nfollowing paragraph";
const result = markdownToIR(input);
// Multi-paragraph blockquote should have proper internal spacing
// AND proper spacing with following content
expect(result.text).toContain("first paragraph\n\nsecond paragraph");
expect(result.text).not.toContain("\n\n\n");
});
});
describe("blockquote prefix option", () => {
it("should include prefix and maintain proper spacing", () => {
const input = "> quote\n\nparagraph";
const result = markdownToIR(input, { blockquotePrefix: "> " });
// With prefix, should still have proper spacing
expect(result.text).toBe("> quote\n\nparagraph");
expect(result.text).not.toContain("\n\n\n");
});
});
describe("edge cases", () => {
it("should handle empty blockquote followed by paragraph", () => {
const input = ">\n\nparagraph";
const result = markdownToIR(input);
expect(result.text).not.toContain("\n\n\n");
});
it("should handle blockquote at end of document", () => {
const input = "paragraph\n\n> quote";
const result = markdownToIR(input);
// No trailing triple newlines
expect(result.text).not.toContain("\n\n\n");
});
it("should handle multiple blockquotes with paragraphs between", () => {
const input = "> first\n\nparagraph\n\n> second";
const result = markdownToIR(input);
expect(result.text).toBe("first\n\nparagraph\n\nsecond");
expect(result.text).not.toContain("\n\n\n");
});
});
});
describe("comparison with other block elements (control group)", () => {
it("paragraphs should have double newline separation", () => {
const input = "paragraph 1\n\nparagraph 2";
const result = markdownToIR(input);
expect(result.text).toBe("paragraph 1\n\nparagraph 2");
expect(result.text).not.toContain("\n\n\n");
});
it("list followed by paragraph should have double newline", () => {
const input = "- item 1\n- item 2\n\nparagraph";
const result = markdownToIR(input);
// Lists already work correctly
expect(result.text).toContain("• item 2\n\nparagraph");
expect(result.text).not.toContain("\n\n\n");
});
it("heading followed by paragraph should have double newline", () => {
const input = "# Heading\n\nparagraph";
const result = markdownToIR(input);
expect(result.text).toBe("Heading\n\nparagraph");
expect(result.text).not.toContain("\n\n\n");
});
});
+173
View File
@@ -0,0 +1,173 @@
import { describe, it, expect } from "vitest";
import { markdownToIR } from "./ir.js";
/**
* HR (Thematic Break) Spacing Analysis
* =====================================
*
* CommonMark Spec (0.31.2) Section 4.1 - Thematic Breaks:
* - Thematic breaks (---, ***, ___) produce <hr /> in HTML
* - "Thematic breaks do not need blank lines before or after"
* - A thematic break can interrupt a paragraph
*
* HTML Output per spec:
* Input: "Foo\n***\nbar"
* HTML: "<p>Foo</p>\n<hr />\n<p>bar</p>"
*
* PLAIN TEXT OUTPUT DECISION:
*
* The HR element is a block-level thematic separator. In plain text output,
* we render HRs as a visible separator "───" to maintain visual distinction.
*/
describe("hr (thematic break) spacing", () => {
describe("current behavior documentation", () => {
it("just hr alone renders as separator", () => {
const result = markdownToIR("---");
expect(result.text).toBe("───");
});
it("hr between paragraphs renders with separator", () => {
const input = `Para 1
---
Para 2`;
const result = markdownToIR(input);
expect(result.text).toBe("Para 1\n\n───\n\nPara 2");
});
it("hr interrupting paragraph (setext heading case)", () => {
// Note: "Para\n---" is a setext heading in CommonMark!
// Using *** to test actual HR behavior
const input = `Para 1
***
Para 2`;
const result = markdownToIR(input);
// HR interrupts para, renders visibly
expect(result.text).toContain("───");
});
});
describe("expected behavior (tests assert CORRECT behavior)", () => {
it("hr between paragraphs should render with separator", () => {
const input = `Para 1
---
Para 2`;
const result = markdownToIR(input);
expect(result.text).toBe("Para 1\n\n───\n\nPara 2");
});
it("hr between paragraphs using *** should render with separator", () => {
const input = `Para 1
***
Para 2`;
const result = markdownToIR(input);
expect(result.text).toBe("Para 1\n\n───\n\nPara 2");
});
it("hr between paragraphs using ___ should render with separator", () => {
const input = `Para 1
___
Para 2`;
const result = markdownToIR(input);
expect(result.text).toBe("Para 1\n\n───\n\nPara 2");
});
it("consecutive hrs should produce multiple separators", () => {
const input = `---
---
---`;
const result = markdownToIR(input);
// Each HR renders as a separator
expect(result.text).toBe("───\n\n───\n\n───");
});
it("hr at document end renders separator", () => {
const input = `Para
---`;
const result = markdownToIR(input);
expect(result.text).toBe("Para\n\n───");
});
it("hr at document start renders separator", () => {
const input = `---
Para`;
const result = markdownToIR(input);
expect(result.text).toBe("───\n\nPara");
});
it("should not produce triple newlines regardless of hr placement", () => {
const inputs = [
"Para 1\n\n---\n\nPara 2",
"Para 1\n---\nPara 2",
"---\nPara",
"Para\n---",
"Para 1\n\n---\n\n---\n\nPara 2",
"Para 1\n\n***\n\n---\n\n___\n\nPara 2",
];
for (const input of inputs) {
const result = markdownToIR(input);
expect(result.text, `Input: ${JSON.stringify(input)}`).not.toMatch(/\n{3,}/);
}
});
it("multiple consecutive hrs between paragraphs should each render as separator", () => {
const input = `Para 1
---
---
---
Para 2`;
const result = markdownToIR(input);
expect(result.text).toBe("Para 1\n\n───\n\n───\n\n───\n\nPara 2");
});
});
describe("edge cases", () => {
it("hr between list items renders as separator without extra spacing", () => {
const input = `- Item 1
- ---
- Item 2`;
const result = markdownToIR(input);
expect(result.text).toBe("• Item 1\n\n───\n\n• Item 2");
expect(result.text).not.toMatch(/\n{3,}/);
});
it("hr followed immediately by heading", () => {
const input = `---
# Heading
Para`;
const result = markdownToIR(input);
// HR renders as separator, heading renders, para follows
expect(result.text).not.toMatch(/\n{3,}/);
expect(result.text).toContain("───");
});
it("heading followed by hr", () => {
const input = `# Heading
---
Para`;
const result = markdownToIR(input);
// Heading ends, HR renders, para follows
expect(result.text).not.toMatch(/\n{3,}/);
expect(result.text).toContain("───");
});
});
});
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect } from "vitest";
import { markdownToIR } from "./ir.js";
describe("list paragraph spacing", () => {
it("adds blank line between bullet list and following paragraph", () => {
const input = `- item 1
- item 2
Paragraph after`;
const result = markdownToIR(input);
// Should have two newlines between "item 2" and "Paragraph"
expect(result.text).toContain("item 2\n\nParagraph");
});
it("adds blank line between ordered list and following paragraph", () => {
const input = `1. item 1
2. item 2
Paragraph after`;
const result = markdownToIR(input);
expect(result.text).toContain("item 2\n\nParagraph");
});
it("does not produce triple newlines", () => {
const input = `- item 1
- item 2
Paragraph after`;
const result = markdownToIR(input);
// Should NOT have three consecutive newlines
expect(result.text).not.toContain("\n\n\n");
});
});
+301
View File
@@ -0,0 +1,301 @@
/**
* Nested List Rendering Tests
*
* This test file documents and validates the expected behavior for nested lists
* when rendering Markdown to plain text.
*
* ## Expected Plain Text Behavior
*
* Per CommonMark spec, nested lists create a hierarchical structure. When rendering
* to plain text for messaging platforms, we expect:
*
* 1. **Indentation**: Each nesting level adds 2 spaces of indentation
* 2. **Bullet markers**: Bullet lists use "•" (Unicode bullet)
* 3. **Ordered markers**: Ordered lists use "N. " format
* 4. **Line endings**: Each list item ends with a single newline
* 5. **List termination**: A trailing newline after the entire list (for top-level only)
*
* ## markdown-it Token Sequence
*
* For nested lists, markdown-it emits tokens in this order:
* - bullet_list_open (outer)
* - list_item_open
* - paragraph_open (hidden=true for tight lists)
* - inline (with text children)
* - paragraph_close
* - bullet_list_open (nested)
* - list_item_open
* - paragraph_open
* - inline
* - paragraph_close
* - list_item_close
* - bullet_list_close
* - list_item_close
* - bullet_list_close
*
* The key insight is that nested lists appear INSIDE the parent list_item,
* between the paragraph and the list_item_close.
*/
import { describe, it, expect } from "vitest";
import { markdownToIR } from "./ir.js";
describe("Nested Lists - 2 Level Nesting", () => {
it("renders bullet items nested inside bullet items with proper indentation", () => {
const input = `- Item 1
- Nested 1.1
- Nested 1.2
- Item 2`;
const result = markdownToIR(input);
// Expected output:
// • Item 1
// • Nested 1.1
// • Nested 1.2
// • Item 2
// Note: markdownToIR trims trailing whitespace, so no final newline
const expected = `• Item 1
• Nested 1.1
• Nested 1.2
• Item 2`;
expect(result.text).toBe(expected);
});
it("renders ordered items nested inside bullet items", () => {
const input = `- Bullet item
1. Ordered sub-item 1
2. Ordered sub-item 2
- Another bullet`;
const result = markdownToIR(input);
// Expected output:
// • Bullet item
// 1. Ordered sub-item 1
// 2. Ordered sub-item 2
// • Another bullet
const expected = `• Bullet item
1. Ordered sub-item 1
2. Ordered sub-item 2
• Another bullet`;
expect(result.text).toBe(expected);
});
it("renders bullet items nested inside ordered items", () => {
const input = `1. Ordered 1
- Bullet sub 1
- Bullet sub 2
2. Ordered 2`;
const result = markdownToIR(input);
// Expected output:
// 1. Ordered 1
// • Bullet sub 1
// • Bullet sub 2
// 2. Ordered 2
const expected = `1. Ordered 1
• Bullet sub 1
• Bullet sub 2
2. Ordered 2`;
expect(result.text).toBe(expected);
});
it("renders ordered items nested inside ordered items", () => {
const input = `1. First
1. Sub-first
2. Sub-second
2. Second`;
const result = markdownToIR(input);
const expected = `1. First
1. Sub-first
2. Sub-second
2. Second`;
expect(result.text).toBe(expected);
});
});
describe("Nested Lists - 3+ Level Deep Nesting", () => {
it("renders 3 levels of bullet nesting", () => {
const input = `- Level 1
- Level 2
- Level 3
- Back to 1`;
const result = markdownToIR(input);
// Expected output with progressive indentation:
// • Level 1
// • Level 2
// • Level 3
// • Back to 1
const expected = `• Level 1
• Level 2
• Level 3
• Back to 1`;
expect(result.text).toBe(expected);
});
it("renders 4 levels of bullet nesting", () => {
const input = `- L1
- L2
- L3
- L4
- Back`;
const result = markdownToIR(input);
const expected = `• L1
• L2
• L3
• L4
• Back`;
expect(result.text).toBe(expected);
});
it("renders 3 levels with multiple items at each level", () => {
const input = `- A1
- B1
- C1
- C2
- B2
- A2`;
const result = markdownToIR(input);
const expected = `• A1
• B1
• C1
• C2
• B2
• A2`;
expect(result.text).toBe(expected);
});
});
describe("Nested Lists - Mixed Nesting", () => {
it("renders complex mixed nesting (bullet > ordered > bullet)", () => {
const input = `- Bullet 1
1. Ordered 1.1
- Deep bullet
2. Ordered 1.2
- Bullet 2`;
const result = markdownToIR(input);
const expected = `• Bullet 1
1. Ordered 1.1
• Deep bullet
2. Ordered 1.2
• Bullet 2`;
expect(result.text).toBe(expected);
});
it("renders ordered > bullet > ordered nesting", () => {
const input = `1. First
- Sub bullet
1. Deep ordered
- Another bullet
2. Second`;
const result = markdownToIR(input);
const expected = `1. First
• Sub bullet
1. Deep ordered
• Another bullet
2. Second`;
expect(result.text).toBe(expected);
});
});
describe("Nested Lists - Newline Handling", () => {
it("does not produce triple newlines in nested lists", () => {
const input = `- Item 1
- Nested
- Item 2`;
const result = markdownToIR(input);
expect(result.text).not.toContain("\n\n\n");
});
it("does not produce double newlines between nested items", () => {
const input = `- A
- B
- C
- D`;
const result = markdownToIR(input);
// Between B and C there should be exactly one newline
expect(result.text).toContain(" • B\n • C");
expect(result.text).not.toContain(" • B\n\n • C");
});
it("properly terminates top-level list (trimmed output)", () => {
const input = `- Item 1
- Nested
- Item 2`;
const result = markdownToIR(input);
// markdownToIR trims trailing whitespace, so output should end with Item 2
// (no trailing newline after trimming)
expect(result.text).toMatch(/Item 2$/);
// Should not have excessive newlines before Item 2
expect(result.text).not.toContain("\n\n• Item 2");
});
});
describe("Nested Lists - Edge Cases", () => {
it("handles empty parent with nested items", () => {
// This is a bit of an edge case - a list item that's just a marker followed by nested content
const input = `-
- Nested only
- Normal`;
const result = markdownToIR(input);
// Should still render the nested item with proper indentation
expect(result.text).toContain(" • Nested only");
});
it("handles nested list as first child of parent item", () => {
const input = `- Parent text
- Child
- Another parent`;
const result = markdownToIR(input);
// The child should appear indented under the parent
expect(result.text).toContain("• Parent text\n • Child");
});
it("handles sibling nested lists at same level", () => {
const input = `- A
- A1
- B
- B1`;
const result = markdownToIR(input);
const expected = `• A
• A1
• B
• B1`;
expect(result.text).toBe(expected);
});
});
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import { markdownToIR } from "./ir.js";
describe("markdownToIR tableMode code - style overlap", () => {
it("should not have overlapping styles when cell has bold text", () => {
const md = `
| Name | Value |
|------|-------|
| **Bold** | Normal |
`.trim();
const ir = markdownToIR(md, { tableMode: "code" });
// Check for overlapping styles
const codeBlockSpan = ir.styles.find((s) => s.style === "code_block");
const boldSpan = ir.styles.find((s) => s.style === "bold");
// Either:
// 1. There should be no bold spans in code mode (inner styles stripped), OR
// 2. If bold spans exist, they should not overlap with code_block span
if (codeBlockSpan && boldSpan) {
// Check for overlap
const overlaps = boldSpan.start < codeBlockSpan.end && boldSpan.end > codeBlockSpan.start;
// Overlapping styles are the bug - this should fail until fixed
expect(overlaps).toBe(false);
}
});
it("should not have overlapping styles when cell has italic text", () => {
const md = `
| Name | Value |
|------|-------|
| *Italic* | Normal |
`.trim();
const ir = markdownToIR(md, { tableMode: "code" });
const codeBlockSpan = ir.styles.find((s) => s.style === "code_block");
const italicSpan = ir.styles.find((s) => s.style === "italic");
if (codeBlockSpan && italicSpan) {
const overlaps = italicSpan.start < codeBlockSpan.end && italicSpan.end > codeBlockSpan.start;
expect(overlaps).toBe(false);
}
});
it("should not have overlapping styles when cell has inline code", () => {
const md = `
| Name | Value |
|------|-------|
| \`code\` | Normal |
`.trim();
const ir = markdownToIR(md, { tableMode: "code" });
const codeBlockSpan = ir.styles.find((s) => s.style === "code_block");
const codeSpan = ir.styles.find((s) => s.style === "code");
if (codeBlockSpan && codeSpan) {
const overlaps = codeSpan.start < codeBlockSpan.end && codeSpan.end > codeBlockSpan.start;
expect(overlaps).toBe(false);
}
});
it("should not have overlapping styles with multiple styled cells", () => {
const md = `
| Name | Value |
|------|-------|
| **A** | *B* |
| _C_ | ~~D~~ |
`.trim();
const ir = markdownToIR(md, { tableMode: "code" });
const codeBlockSpan = ir.styles.find((s) => s.style === "code_block");
if (!codeBlockSpan) {
return;
}
// Check that no non-code_block style overlaps with code_block
for (const style of ir.styles) {
if (style.style === "code_block") {
continue;
}
const overlaps = style.start < codeBlockSpan.end && style.end > codeBlockSpan.start;
expect(overlaps).toBe(false);
}
});
});
+37 -5
View File
@@ -364,6 +364,14 @@ function appendCell(state: RenderState, cell: TableCell) {
}
}
function appendCellTextOnly(state: RenderState, cell: TableCell) {
if (!cell.text) {
return;
}
state.text += cell.text;
// Do not append styles - this is used for code blocks where inner styles would overlap
}
function renderTableAsBullets(state: RenderState) {
if (!state.table) {
return;
@@ -474,7 +482,8 @@ function renderTableAsCode(state: RenderState) {
state.text += " ";
const cell = cells[i];
if (cell) {
appendCell(state, cell);
// Use text-only append to avoid overlapping styles with code_block
appendCellTextOnly(state, cell);
}
const pad = widths[i] - (cell?.text.length ?? 0);
if (pad > 0) {
@@ -589,27 +598,43 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
break;
case "blockquote_close":
closeStyle(state, "blockquote");
state.text += "\n";
break;
case "bullet_list_open":
// Add newline before nested list starts (so nested items appear on new line)
if (state.env.listStack.length > 0) {
state.text += "\n";
}
state.env.listStack.push({ type: "bullet", index: 0 });
break;
case "bullet_list_close":
state.env.listStack.pop();
if (state.env.listStack.length === 0) {
state.text += "\n";
}
break;
case "ordered_list_open": {
// Add newline before nested list starts (so nested items appear on new line)
if (state.env.listStack.length > 0) {
state.text += "\n";
}
const start = Number(getAttr(token, "start") ?? "1");
state.env.listStack.push({ type: "ordered", index: start - 1 });
break;
}
case "ordered_list_close":
state.env.listStack.pop();
if (state.env.listStack.length === 0) {
state.text += "\n";
}
break;
case "list_item_open":
appendListPrefix(state);
break;
case "list_item_close":
state.text += "\n";
// Avoid double newlines (nested list's last item already added newline)
if (!state.text.endsWith("\n")) {
state.text += "\n";
}
break;
case "code_block":
case "fence":
@@ -680,7 +705,8 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
break;
case "hr":
state.text += "\n";
// Render as a visual separator
state.text += "───\n\n";
break;
default:
if (token.children) {
@@ -744,7 +770,13 @@ function mergeStyleSpans(spans: MarkdownStyleSpan[]): MarkdownStyleSpan[] {
const merged: MarkdownStyleSpan[] = [];
for (const span of sorted) {
const prev = merged[merged.length - 1];
if (prev && prev.style === span.style && span.start <= prev.end) {
if (
prev &&
prev.style === span.style &&
// Blockquotes are container blocks. Adjacent blockquote spans should not merge or
// consecutive blockquotes can "style bleed" across the paragraph boundary.
(span.start < prev.end || (span.start === prev.end && span.style !== "blockquote"))
) {
prev.end = Math.max(prev.end, span.end);
continue;
}