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 <steipete@gmail.com>
This commit is contained in:
Simone
2026-04-29 18:56:51 +02:00
committed by GitHub
parent 3215ab6de5
commit 630629667c
3 changed files with 114 additions and 9 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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":