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);
}
}
}