fix(tui): blockquote multiline rendering and wrapping (#1073)

* fix(tui): blockquote multiline rendering and wrapping

Fix two bugs in blockquote rendering:

1. ANSI codes split incorrectly on newlines - when text tokens contain
   newlines, applyDefaultStyle() wrapped the entire string including the
   newline in ANSI codes. Any caller splitting by \n would break the codes.
   Fixed by splitting text by newlines before styling in renderInlineTokens().

2. Wrapped blockquote lines lost the │ border - long blockquote lines
   that wrapped to multiple lines only had the border on the first line.
   Fixed by wrapping within the blockquote case and adding border to each line.

* test(tui): add test for inline formatting inside blockquotes
This commit is contained in:
Fero 2026-01-30 03:02:38 +01:00 committed by GitHub
parent ab37d661af
commit 20ca6836b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 214 additions and 21 deletions

View file

@ -46,6 +46,11 @@ export interface MarkdownTheme {
codeBlockIndent?: string;
}
interface InlineStyleContext {
applyText: (text: string) => string;
stylePrefix: string;
}
export class Markdown implements Component {
private text: string;
private paddingX: number; // Left/right padding
@ -241,6 +246,20 @@ export class Markdown implements Component {
return this.defaultStylePrefix;
}
private getStylePrefix(styleFn: (text: string) => string): string {
const sentinel = "\u0000";
const styled = styleFn(sentinel);
const sentinelIndex = styled.indexOf(sentinel);
return sentinelIndex >= 0 ? styled.slice(0, sentinelIndex) : "";
}
private getDefaultInlineStyleContext(): InlineStyleContext {
return {
applyText: (text: string) => this.applyDefaultStyle(text),
stylePrefix: this.getDefaultStylePrefix(),
};
}
private renderToken(token: Token, width: number, nextTokenType?: string): string[] {
const lines: string[] = [];
@ -311,10 +330,23 @@ export class Markdown implements Component {
}
case "blockquote": {
const quoteText = this.renderInlineTokens(token.tokens || []);
const quoteStyle = (text: string) => this.theme.quote(this.theme.italic(text));
const quoteStyleContext: InlineStyleContext = {
applyText: quoteStyle,
stylePrefix: this.getStylePrefix(quoteStyle),
};
const quoteText = this.renderInlineTokens(token.tokens || [], quoteStyleContext);
const quoteLines = quoteText.split("\n");
// Calculate available width for quote content (subtract border "│ " = 2 chars)
const quoteContentWidth = Math.max(1, width - 2);
for (const quoteLine of quoteLines) {
lines.push(this.theme.quoteBorder("│ ") + this.theme.quote(this.theme.italic(quoteLine)));
// Wrap the styled line, then add border to each wrapped line
const wrappedLines = wrapTextWithAnsi(quoteLine, quoteContentWidth);
for (const wrappedLine of wrappedLines) {
lines.push(this.theme.quoteBorder("│ ") + wrappedLine);
}
}
if (nextTokenType !== "space") {
lines.push(""); // Add spacing after blockquotes (unless space token follows)
@ -351,54 +383,61 @@ export class Markdown implements Component {
return lines;
}
private renderInlineTokens(tokens: Token[]): string {
private renderInlineTokens(tokens: Token[], styleContext?: InlineStyleContext): string {
let result = "";
const resolvedStyleContext = styleContext ?? this.getDefaultInlineStyleContext();
const { applyText, stylePrefix } = resolvedStyleContext;
const applyTextWithNewlines = (text: string): string => {
const segments: string[] = text.split("\n");
return segments.map((segment: string) => applyText(segment)).join("\n");
};
for (const token of tokens) {
switch (token.type) {
case "text":
// Text tokens in list items can have nested tokens for inline formatting
if (token.tokens && token.tokens.length > 0) {
result += this.renderInlineTokens(token.tokens);
result += this.renderInlineTokens(token.tokens, resolvedStyleContext);
} else {
// Apply default style to plain text
result += this.applyDefaultStyle(token.text);
result += applyTextWithNewlines(token.text);
}
break;
case "paragraph":
// Paragraph tokens contain nested inline tokens
result += this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
break;
case "strong": {
// Apply bold, then reapply default style after
const boldContent = this.renderInlineTokens(token.tokens || []);
result += this.theme.bold(boldContent) + this.getDefaultStylePrefix();
const boldContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
result += this.theme.bold(boldContent) + stylePrefix;
break;
}
case "em": {
// Apply italic, then reapply default style after
const italicContent = this.renderInlineTokens(token.tokens || []);
result += this.theme.italic(italicContent) + this.getDefaultStylePrefix();
const italicContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
result += this.theme.italic(italicContent) + stylePrefix;
break;
}
case "codespan":
// Apply code styling without backticks
result += this.theme.code(token.text) + this.getDefaultStylePrefix();
result += this.theme.code(token.text) + stylePrefix;
break;
case "link": {
const linkText = this.renderInlineTokens(token.tokens || []);
const linkText = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
// If link text matches href, only show the link once
// Compare raw text (token.text) not styled text (linkText) since linkText has ANSI codes
// For mailto: links, strip the prefix before comparing (autolinked emails have
// text="foo@bar.com" but href="mailto:foo@bar.com")
const hrefForComparison = token.href.startsWith("mailto:") ? token.href.slice(7) : token.href;
if (token.text === token.href || token.text === hrefForComparison) {
result += this.theme.link(this.theme.underline(linkText)) + this.getDefaultStylePrefix();
result += this.theme.link(this.theme.underline(linkText)) + stylePrefix;
} else {
result +=
this.theme.link(this.theme.underline(linkText)) +
this.theme.linkUrl(` (${token.href})`) +
this.getDefaultStylePrefix();
stylePrefix;
}
break;
}
@ -408,22 +447,22 @@ export class Markdown implements Component {
break;
case "del": {
const delContent = this.renderInlineTokens(token.tokens || []);
result += this.theme.strikethrough(delContent) + this.getDefaultStylePrefix();
const delContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
result += this.theme.strikethrough(delContent) + stylePrefix;
break;
}
case "html":
// Render inline HTML as plain text
if ("raw" in token && typeof token.raw === "string") {
result += this.applyDefaultStyle(token.raw);
result += applyTextWithNewlines(token.raw);
}
break;
default:
// Handle any other inline token types as plain text
if ("text" in token && typeof token.text === "string") {
result += this.applyDefaultStyle(token.text);
result += applyTextWithNewlines(token.text);
}
}
}

View file

@ -691,6 +691,160 @@ again, hello world`,
});
});
describe("Blockquotes with multiline content", () => {
it("should apply consistent styling to all lines in lazy continuation blockquote", () => {
// Markdown "lazy continuation" - second line without > is still part of the quote
const markdown = new Markdown(
`>Foo
bar`,
0,
0,
defaultMarkdownTheme,
{
color: (text) => chalk.magenta(text), // This should NOT be applied to blockquotes
},
);
const lines = markdown.render(80);
// Both lines should have the quote border
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
const quotedLines = plainLines.filter((line) => line.startsWith("│ "));
assert.strictEqual(quotedLines.length, 2, `Expected 2 quoted lines, got: ${JSON.stringify(plainLines)}`);
// Both lines should have italic (from theme.quote styling)
const fooLine = lines.find((line) => line.includes("Foo"));
const barLine = lines.find((line) => line.includes("bar"));
assert.ok(fooLine, "Should have Foo line");
assert.ok(barLine, "Should have bar line");
// Check that both have italic (\x1b[3m) - blockquotes use theme styling, not default message color
assert.ok(fooLine?.includes("\x1b[3m"), `Foo line should have italic: ${fooLine}`);
assert.ok(barLine?.includes("\x1b[3m"), `bar line should have italic: ${barLine}`);
// Blockquotes should NOT have the default message color (magenta)
assert.ok(!fooLine?.includes("\x1b[35m"), `Foo line should NOT have magenta color: ${fooLine}`);
assert.ok(!barLine?.includes("\x1b[35m"), `bar line should NOT have magenta color: ${barLine}`);
});
it("should apply consistent styling to explicit multiline blockquote", () => {
const markdown = new Markdown(
`>Foo
>bar`,
0,
0,
defaultMarkdownTheme,
{
color: (text) => chalk.cyan(text), // This should NOT be applied to blockquotes
},
);
const lines = markdown.render(80);
// Both lines should have the quote border
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
const quotedLines = plainLines.filter((line) => line.startsWith("│ "));
assert.strictEqual(quotedLines.length, 2, `Expected 2 quoted lines, got: ${JSON.stringify(plainLines)}`);
// Both lines should have italic (from theme.quote styling)
const fooLine = lines.find((line) => line.includes("Foo"));
const barLine = lines.find((line) => line.includes("bar"));
assert.ok(fooLine?.includes("\x1b[3m"), `Foo line should have italic: ${fooLine}`);
assert.ok(barLine?.includes("\x1b[3m"), `bar line should have italic: ${barLine}`);
// Blockquotes should NOT have the default message color (cyan)
assert.ok(!fooLine?.includes("\x1b[36m"), `Foo line should NOT have cyan color: ${fooLine}`);
assert.ok(!barLine?.includes("\x1b[36m"), `bar line should NOT have cyan color: ${barLine}`);
});
it("should wrap long blockquote lines and add border to each wrapped line", () => {
const longText = "This is a very long blockquote line that should wrap to multiple lines when rendered";
const markdown = new Markdown(`> ${longText}`, 0, 0, defaultMarkdownTheme);
// Render at narrow width to force wrapping
const lines = markdown.render(30);
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
// Filter to non-empty lines (exclude trailing blank line after blockquote)
const contentLines = plainLines.filter((line) => line.length > 0);
// Should have multiple lines due to wrapping
assert.ok(contentLines.length > 1, `Expected multiple wrapped lines, got: ${JSON.stringify(contentLines)}`);
// Every content line should start with the quote border
for (const line of contentLines) {
assert.ok(line.startsWith("│ "), `Wrapped line should have quote border: "${line}"`);
}
// All content should be preserved
const allText = contentLines.join(" ");
assert.ok(allText.includes("very long"), "Should preserve 'very long'");
assert.ok(allText.includes("blockquote"), "Should preserve 'blockquote'");
assert.ok(allText.includes("multiple"), "Should preserve 'multiple'");
});
it("should properly indent wrapped blockquote lines with styling", () => {
const markdown = new Markdown(
"> This is styled text that is long enough to wrap",
0,
0,
defaultMarkdownTheme,
{
color: (text) => chalk.yellow(text), // This should NOT be applied to blockquotes
italic: true,
},
);
const lines = markdown.render(25);
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
// Filter to non-empty lines
const contentLines = plainLines.filter((line) => line.length > 0);
// All lines should have the quote border
for (const line of contentLines) {
assert.ok(line.startsWith("│ "), `Line should have quote border: "${line}"`);
}
// Check that italic is applied (from theme.quote)
const allOutput = lines.join("\n");
assert.ok(allOutput.includes("\x1b[3m"), "Should have italic");
// Blockquotes should NOT have the default message color (yellow)
assert.ok(!allOutput.includes("\x1b[33m"), "Should NOT have yellow color from default style");
});
it("should render inline formatting inside blockquotes and reapply quote styling after", () => {
const markdown = new Markdown("> Quote with **bold** and `code`", 0, 0, defaultMarkdownTheme);
const lines = markdown.render(80);
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
// Should have the quote border
assert.ok(
plainLines.some((line) => line.startsWith("│ ")),
"Should have quote border",
);
// Content should be preserved
const allPlain = plainLines.join(" ");
assert.ok(allPlain.includes("Quote with"), "Should preserve 'Quote with'");
assert.ok(allPlain.includes("bold"), "Should preserve 'bold'");
assert.ok(allPlain.includes("code"), "Should preserve 'code'");
const allOutput = lines.join("\n");
// Should have bold styling (\x1b[1m)
assert.ok(allOutput.includes("\x1b[1m"), "Should have bold styling");
// Should have code styling (yellow = \x1b[33m from defaultMarkdownTheme)
assert.ok(allOutput.includes("\x1b[33m"), "Should have code styling (yellow)");
// Should have italic from quote styling (\x1b[3m)
assert.ok(allOutput.includes("\x1b[3m"), "Should have italic from quote styling");
});
});
describe("Links", () => {
it("should not duplicate URL for autolinked emails", () => {
const markdown = new Markdown("Contact user@example.com for help", 0, 0, defaultMarkdownTheme);