From 630629667c166c4f3bff0eb9da9c4708758353b7 Mon Sep 17 00:00:00 2001 From: Simone Date: Wed, 29 Apr 2026 18:56:51 +0200 Subject: [PATCH] fix(markdown): preserve loose list paragraphs (#74474) * fix(markdown): preserve loose list paragraphs * fix(markdown): avoid loose nested list triples * fix(markdown): keep tight list block spacing * fix(markdown): scope loose list paragraphs * docs(changelog): credit markdown list spacing fix --------- Co-authored-by: Lucenx9 <185146821+Lucenx9@users.noreply.github.com> Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + src/markdown/ir.nested-lists.test.ts | 82 ++++++++++++++++++++++++++++ src/markdown/ir.ts | 40 +++++++++++--- 3 files changed, 114 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36e622fc0e8..905cd1f2087 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai - Exec: reject invalid per-call `host` values instead of silently falling back to the default target, so hostname-like values fail before commands run. Fixes #74426. Thanks @scr00ge-00 and @vyctorbrzezowski. - Google/Gemini: send non-empty placeholder content when a Gemini run is triggered with empty or filtered user content, avoiding `contents is not specified` API errors. Thanks @CaoYuhaoCarl. - Heartbeat: preserve non-task `HEARTBEAT.md` context around `tasks:` blocks and apply `agents.defaults.heartbeat` to all agents unless per-agent heartbeat entries restrict scope. Thanks @Sekhar03. +- Markdown: preserve paragraph breaks inside loose list items in shared outbound formatting while keeping tight list spacing stable. Thanks @Lucenx9. - Build/Gateway: route restart, shutdown, respawn, diagnostics, command-queue cleanup, and runtime cleanup through one stable gateway lifecycle runtime entry so rebuilt packages do not strand long-running gateways on stale hashed chunks. Carries forward #73964. Thanks @pashpashpash. - Memory/wiki: keep broad shared-source and generated related-link blocks from turning every page into a search hit, cap noisy backlinks, support all-term searches such as people-routing queries, and prefer readable page body snippets over generated metadata. Thanks @vincentkoc. - Cron/Gateway: abort and bounded-clean up timed-out isolated agent turns before recording the timeout, so stale cron sessions cannot leave Discord or other chat lanes stuck in `processing` after a timeout. Thanks @vincentkoc. diff --git a/src/markdown/ir.nested-lists.test.ts b/src/markdown/ir.nested-lists.test.ts index 7de8931eb40..5914d063d55 100644 --- a/src/markdown/ir.nested-lists.test.ts +++ b/src/markdown/ir.nested-lists.test.ts @@ -301,6 +301,88 @@ describe("Nested Lists - Edge Cases", () => { }); describe("list paragraph spacing", () => { + it("preserves paragraph breaks inside loose bullet list items", () => { + const input = `- first paragraph + + second paragraph +- next`; + + const result = markdownToIR(input); + + expect(result.text).toBe(`• first paragraph + +second paragraph + +• next`); + }); + + it("preserves paragraph breaks inside loose ordered list items", () => { + const input = `1. first paragraph + + second paragraph +2. next`; + + const result = markdownToIR(input); + + expect(result.text).toBe(`1. first paragraph + +second paragraph + +2. next`); + }); + + it("does not add triple newlines before loose nested bullet lists", () => { + const input = `- parent + + - child + +- next`; + + const result = markdownToIR(input); + + expect(result.text).toBe(`• parent + + • child +• next`); + expect(result.text).not.toContain("\n\n\n"); + }); + + it("does not add triple newlines before loose nested ordered lists", () => { + const input = `1. parent + + 1. child + +2. next`; + + const result = markdownToIR(input); + + expect(result.text).toBe(`1. parent + + 1. child +2. next`); + expect(result.text).not.toContain("\n\n\n"); + }); + + it("keeps tight heading list items single-spaced", () => { + const input = `- # A +- # B`; + + const result = markdownToIR(input); + + expect(result.text).toBe(`• A +• B`); + }); + + it("keeps tight blockquote list items single-spaced", () => { + const input = `- > quote +- next`; + + const result = markdownToIR(input); + + expect(result.text).toBe(`• quote +• next`); + }); + it("adds blank line between bullet list and following paragraph", () => { const input = `- item 1 - item 2 diff --git a/src/markdown/ir.ts b/src/markdown/ir.ts index 3a2454defbb..7e6445dd379 100644 --- a/src/markdown/ir.ts +++ b/src/markdown/ir.ts @@ -22,6 +22,8 @@ type MarkdownToken = { children?: MarkdownToken[]; attrs?: [string, string][]; attrGet?: (name: string) => string | null; + hidden?: boolean; + level?: number; }; export type MarkdownStyle = @@ -262,16 +264,36 @@ function closeStyle(state: RenderState, style: MarkdownStyle) { } } -function appendParagraphSeparator(state: RenderState) { - if (state.env.listStack.length > 0) { - return; - } +function appendParagraphSeparator(state: RenderState, token?: MarkdownToken) { if (state.table) { return; } // Don't add paragraph separators inside tables + if (state.env.listStack.length > 0) { + const directListParagraphLevel = state.env.listStack.length * 2; + if ( + token?.type !== "paragraph_close" || + token.hidden || + token.level !== directListParagraphLevel + ) { + return; + } + } state.text += "\n\n"; } +function appendTopLevelListSeparator(state: RenderState) { + const trailingNewlines = state.text.match(/\n*$/)?.[0].length ?? 0; + if (trailingNewlines < 2) { + state.text += "\n"; + } +} + +function appendNestedListSeparator(state: RenderState) { + if (!state.text.endsWith("\n")) { + state.text += "\n"; + } +} + function appendListPrefix(state: RenderState) { const stack = state.env.listStack; const top = stack[stack.length - 1]; @@ -636,7 +658,7 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void { appendText(state, "\n"); break; case "paragraph_close": - appendParagraphSeparator(state); + appendParagraphSeparator(state, token); break; case "heading_open": if (state.headingStyle === "bold") { @@ -661,20 +683,20 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void { 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"; + appendNestedListSeparator(state); } 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"; + appendTopLevelListSeparator(state); } 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"; + appendNestedListSeparator(state); } const start = Number(getAttr(token, "start") ?? "1"); state.env.listStack.push({ type: "ordered", index: start - 1 }); @@ -683,7 +705,7 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void { case "ordered_list_close": state.env.listStack.pop(); if (state.env.listStack.length === 0) { - state.text += "\n"; + appendTopLevelListSeparator(state); } break; case "list_item_open":