diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 7ab92689..d9e9103b 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -282,7 +282,7 @@ export class Markdown implements Component { styledHeading = this.theme.heading(this.theme.bold(headingPrefix + headingText)); } lines.push(styledHeading); - if (nextTokenType !== "space") { + if (nextTokenType && nextTokenType !== "space") { lines.push(""); // Add spacing after headings (unless space token follows) } break; @@ -314,7 +314,7 @@ export class Markdown implements Component { } } lines.push(this.theme.codeBlockBorder("```")); - if (nextTokenType !== "space") { + if (nextTokenType && nextTokenType !== "space") { lines.push(""); // Add spacing after code blocks (unless space token follows) } break; @@ -329,7 +329,7 @@ export class Markdown implements Component { } case "table": { - const tableLines = this.renderTable(token as any, width, styleContext); + const tableLines = this.renderTable(token as any, width, nextTokenType, styleContext); lines.push(...tableLines); break; } @@ -377,7 +377,7 @@ export class Markdown implements Component { lines.push(this.theme.quoteBorder("│ ") + wrappedLine); } } - if (nextTokenType !== "space") { + if (nextTokenType && nextTokenType !== "space") { lines.push(""); // Add spacing after blockquotes (unless space token follows) } break; @@ -385,7 +385,7 @@ export class Markdown implements Component { case "hr": lines.push(this.theme.hr("─".repeat(Math.min(width, 80)))); - if (nextTokenType !== "space") { + if (nextTokenType && nextTokenType !== "space") { lines.push(""); // Add spacing after horizontal rules (unless space token follows) } break; @@ -638,6 +638,7 @@ export class Markdown implements Component { private renderTable( token: Token & { header: any[]; rows: any[][]; raw?: string }, availableWidth: number, + nextTokenType?: string, styleContext?: InlineStyleContext, ): string[] { const lines: string[] = []; @@ -654,7 +655,9 @@ export class Markdown implements Component { if (availableForCells < numCols) { // Too narrow to render a stable table. Fall back to raw markdown. const fallbackLines = token.raw ? wrapTextWithAnsi(token.raw, availableWidth) : []; - fallbackLines.push(""); + if (nextTokenType && nextTokenType !== "space") { + fallbackLines.push(""); + } return fallbackLines; } @@ -800,7 +803,9 @@ export class Markdown implements Component { const bottomBorderCells = columnWidths.map((w) => "─".repeat(w)); lines.push(`└─${bottomBorderCells.join("─┴─")}─┘`); - lines.push(""); // Add spacing after table + if (nextTokenType && nextTokenType !== "space") { + lines.push(""); // Add spacing after table + } return lines; } } diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index 2bf859bb..a8e3371f 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -447,6 +447,26 @@ describe("Markdown component", () => { const tableRow = plainLines.find((line) => line.includes("│")); assert.ok(tableRow?.startsWith(" "), "Table should have left padding"); }); + + it("should not add a trailing blank line when table is the last rendered block", () => { + const markdown = new Markdown( + `| Name | +| --- | +| Alice |`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + assert.notStrictEqual( + plainLines.at(-1), + "", + `Expected table to end without a blank line: ${JSON.stringify(plainLines)}`, + ); + }); }); describe("Combined features", () => { @@ -601,6 +621,52 @@ again, hello world`, `Expected 1 empty line after code block, but found ${emptyLineCount}. Lines after backticks: ${JSON.stringify(afterBackticks.slice(0, 5))}`, ); }); + + it("should normalize paragraph and code block spacing to one blank line", () => { + const cases = [ + `hello this is text +\`\`\` +code block +\`\`\` +more text`, + `hello this is text + +\`\`\` +code block +\`\`\` + +more text`, + ]; + const expectedLines = ["hello this is text", "", "```", " code block", "```", "", "more text"]; + + for (const text of cases) { + const markdown = new Markdown(text, 0, 0, defaultMarkdownTheme); + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + assert.deepStrictEqual( + plainLines, + expectedLines, + `Unexpected spacing for markdown: ${JSON.stringify(text)}`, + ); + } + }); + + it("should not add a trailing blank line when code block is the last rendered block", () => { + const cases = ["```js\nconst hello = 'world';\n```", "hello world\n\n```js\nconst hello = 'world';\n```"]; + + for (const text of cases) { + const markdown = new Markdown(text, 0, 0, defaultMarkdownTheme); + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + assert.notStrictEqual( + plainLines.at(-1), + "", + `Expected code block to end without a blank line: ${JSON.stringify(plainLines)}`, + ); + } + }); }); describe("Spacing after dividers", () => { @@ -631,6 +697,18 @@ again, hello world`, `Expected 1 empty line after divider, but found ${emptyLineCount}. Lines after divider: ${JSON.stringify(afterDivider.slice(0, 5))}`, ); }); + + it("should not add a trailing blank line when divider is the last rendered block", () => { + const markdown = new Markdown("---", 0, 0, defaultMarkdownTheme); + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + assert.notStrictEqual( + plainLines.at(-1), + "", + `Expected divider to end without a blank line: ${JSON.stringify(plainLines)}`, + ); + }); }); describe("Spacing after headings", () => { @@ -659,6 +737,18 @@ This is a paragraph`, `Expected 1 empty line after heading, but found ${emptyLineCount}. Lines after heading: ${JSON.stringify(afterHeading.slice(0, 5))}`, ); }); + + it("should not add a trailing blank line when heading is the last rendered block", () => { + const markdown = new Markdown("# Hello", 0, 0, defaultMarkdownTheme); + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + assert.notStrictEqual( + plainLines.at(-1), + "", + `Expected heading to end without a blank line: ${JSON.stringify(plainLines)}`, + ); + }); }); describe("Spacing after blockquotes", () => { @@ -689,6 +779,18 @@ again, hello world`, `Expected 1 empty line after blockquote, but found ${emptyLineCount}. Lines after quote: ${JSON.stringify(afterQuote.slice(0, 5))}`, ); }); + + it("should not add a trailing blank line when blockquote is the last rendered block", () => { + const markdown = new Markdown("> This is a quote", 0, 0, defaultMarkdownTheme); + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + assert.notStrictEqual( + plainLines.at(-1), + "", + `Expected blockquote to end without a blank line: ${JSON.stringify(plainLines)}`, + ); + }); }); describe("Blockquotes with multiline content", () => {