mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 17:50:45 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user