fix(tui): preserve heading styling after inline code spans
Headings applied styling (bold+cyan) as an outer wrapper around the result of renderInlineTokens. When inline elements like codespan emitted ANSI resets, the outer heading style was killed and only the default text style was restored via stylePrefix. Fix: pass a heading-specific InlineStyleContext into renderInlineTokens so each text segment gets heading styling directly, and stylePrefix restores heading style after inline elements.
This commit is contained in:
@@ -272,15 +272,24 @@ export class Markdown implements Component {
|
||||
case "heading": {
|
||||
const headingLevel = token.depth;
|
||||
const headingPrefix = `${"#".repeat(headingLevel)} `;
|
||||
const headingText = this.renderInlineTokens(token.tokens || [], styleContext);
|
||||
let styledHeading: string;
|
||||
|
||||
// Build a heading-specific style context so inline tokens (codespan, bold, etc.)
|
||||
// restore heading styling after their own ANSI resets instead of falling back to
|
||||
// the default text style.
|
||||
let headingStyleFn: (text: string) => string;
|
||||
if (headingLevel === 1) {
|
||||
styledHeading = this.theme.heading(this.theme.bold(this.theme.underline(headingText)));
|
||||
} else if (headingLevel === 2) {
|
||||
styledHeading = this.theme.heading(this.theme.bold(headingText));
|
||||
headingStyleFn = (text: string) => this.theme.heading(this.theme.bold(this.theme.underline(text)));
|
||||
} else {
|
||||
styledHeading = this.theme.heading(this.theme.bold(headingPrefix + headingText));
|
||||
headingStyleFn = (text: string) => this.theme.heading(this.theme.bold(text));
|
||||
}
|
||||
|
||||
const headingStyleContext: InlineStyleContext = {
|
||||
applyText: headingStyleFn,
|
||||
stylePrefix: this.getStylePrefix(headingStyleFn),
|
||||
};
|
||||
|
||||
const headingText = this.renderInlineTokens(token.tokens || [], headingStyleContext);
|
||||
const styledHeading = headingLevel >= 3 ? headingStyleFn(headingPrefix) + headingText : headingText;
|
||||
lines.push(styledHeading);
|
||||
if (nextTokenType && nextTokenType !== "space") {
|
||||
lines.push(""); // Add spacing after headings (unless space token follows)
|
||||
|
||||
@@ -970,6 +970,67 @@ bar`,
|
||||
});
|
||||
});
|
||||
|
||||
describe("Heading with inline code", () => {
|
||||
it("should preserve heading styling after inline code", () => {
|
||||
const markdown = new Markdown("### Why `sourceInfo` should not be optional", 0, 0, defaultMarkdownTheme);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
const joinedOutput = lines.join("\n");
|
||||
|
||||
// The heading theme is bold+cyan. After the yellow inline code, the heading
|
||||
// styling (bold+cyan) must be restored so subsequent text is styled correctly.
|
||||
// bold = \x1b[1m, cyan = \x1b[36m, yellow = \x1b[33m
|
||||
assert.ok(joinedOutput.includes("\x1b[33m"), "Should have yellow for inline code");
|
||||
|
||||
// Find the position of "should not be optional" in the raw output.
|
||||
// It must be preceded by heading style codes (bold+cyan), not appear unstyled.
|
||||
const afterCodeIndex = joinedOutput.indexOf("should not be optional");
|
||||
assert.ok(afterCodeIndex > 0, "Should contain text after inline code");
|
||||
|
||||
// Look at the ANSI codes between the code span end and "should not be optional".
|
||||
// There should be bold (\x1b[1m) and cyan (\x1b[36m) re-applied.
|
||||
const precedingChunk = joinedOutput.slice(Math.max(0, afterCodeIndex - 40), afterCodeIndex);
|
||||
assert.ok(
|
||||
precedingChunk.includes("\x1b[1m"),
|
||||
`Should re-apply bold before text after code: ${precedingChunk}`,
|
||||
);
|
||||
assert.ok(
|
||||
precedingChunk.includes("\x1b[36m"),
|
||||
`Should re-apply cyan before text after code: ${precedingChunk}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should preserve heading styling after inline code for h1", () => {
|
||||
const markdown = new Markdown("# Title with `code` inside", 0, 0, defaultMarkdownTheme);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
const joinedOutput = lines.join("\n");
|
||||
|
||||
const afterCodeIndex = joinedOutput.indexOf("inside");
|
||||
assert.ok(afterCodeIndex > 0, "Should contain text after inline code");
|
||||
|
||||
const precedingChunk = joinedOutput.slice(Math.max(0, afterCodeIndex - 40), afterCodeIndex);
|
||||
// H1 uses heading + bold + underline
|
||||
assert.ok(precedingChunk.includes("\x1b[1m"), `Should re-apply bold for h1: ${precedingChunk}`);
|
||||
assert.ok(precedingChunk.includes("\x1b[36m"), `Should re-apply cyan for h1: ${precedingChunk}`);
|
||||
assert.ok(precedingChunk.includes("\x1b[4m"), `Should re-apply underline for h1: ${precedingChunk}`);
|
||||
});
|
||||
|
||||
it("should preserve heading styling after bold text", () => {
|
||||
const markdown = new Markdown("## Heading with **bold** and more", 0, 0, defaultMarkdownTheme);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
const joinedOutput = lines.join("\n");
|
||||
|
||||
const afterBoldIndex = joinedOutput.indexOf("and more");
|
||||
assert.ok(afterBoldIndex > 0, "Should contain text after bold");
|
||||
|
||||
const precedingChunk = joinedOutput.slice(Math.max(0, afterBoldIndex - 40), afterBoldIndex);
|
||||
assert.ok(precedingChunk.includes("\x1b[1m"), `Should re-apply bold for h2: ${precedingChunk}`);
|
||||
assert.ok(precedingChunk.includes("\x1b[36m"), `Should re-apply cyan for h2: ${precedingChunk}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Links", () => {
|
||||
it("should not duplicate URL for autolinked emails", () => {
|
||||
const markdown = new Markdown("Contact user@example.com for help", 0, 0, defaultMarkdownTheme);
|
||||
|
||||
Reference in New Issue
Block a user