Skip to content

Commit 4d7dff4

Browse files
authored
🤖 fix: trim phantom blank line in highlighted code blocks (#1128)
Fixes a markdown rendering issue where a ` ```text ` code fence containing only a URL could render with an extra blank line. - Treat visually-empty Shiki lines (e.g. `<span></span>`) as empty when extracting per-line HTML - Add a unit test for `extractShikiLines` - Extend the existing App/Markdown → CodeBlocks story with a play assertion to cover the regression _Generated with `mux`_
1 parent 54d48ad commit 4d7dff4

File tree

4 files changed

+111
-3
lines changed

4 files changed

+111
-3
lines changed

src/browser/stories/App.markdown.stories.tsx

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,20 @@
44

55
import { appMeta, AppWithMocks, type AppStory } from "./meta.js";
66
import { STABLE_TIMESTAMP, createUserMessage, createAssistantMessage } from "./mockFactory";
7+
import { expect, waitFor } from "@storybook/test";
8+
9+
async function waitForChatMessagesLoaded(canvasElement: HTMLElement): Promise<void> {
10+
await waitFor(
11+
() => {
12+
const messageWindow = canvasElement.querySelector('[data-testid="message-window"]');
13+
if (!messageWindow || messageWindow.getAttribute("data-loaded") !== "true") {
14+
throw new Error("Messages not loaded yet");
15+
}
16+
},
17+
{ timeout: 5000 }
18+
);
19+
}
20+
721
import { setupSimpleChatStory } from "./storyHelpers";
822

923
export default {
@@ -87,6 +101,18 @@ describe('getUser', () => {
87101
expect(res.status).toBe(401);
88102
});
89103
});
104+
\`\`\`
105+
106+
Text code blocks (regression: no phantom trailing blank line after highlighting):
107+
108+
\`\`\`text
109+
https://github.com/coder/mux/pull/new/chat-autocomplete-b24r
110+
\`\`\`
111+
112+
Code blocks without language (regression: avoid extra vertical spacing):
113+
114+
\`\`\`
115+
65d02772b 🤖 feat: Settings-driven model selector with visibility controls
90116
\`\`\``;
91117

92118
// ═══════════════════════════════════════════════════════════════════════════════
@@ -160,4 +186,56 @@ export const CodeBlocks: AppStory = {
160186
}
161187
/>
162188
),
189+
play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
190+
await waitForChatMessagesLoaded(canvasElement);
191+
192+
const url = "https://github.com/coder/mux/pull/new/chat-autocomplete-b24r";
193+
194+
// Find the highlighted code block containing the URL.
195+
const container = await waitFor(
196+
() => {
197+
const candidates = Array.from(canvasElement.querySelectorAll(".code-block-container"));
198+
const found = candidates.find((el) => el.textContent?.includes(url));
199+
if (!found) {
200+
throw new Error("URL code block not found");
201+
}
202+
return found;
203+
},
204+
{ timeout: 5000 }
205+
);
206+
207+
// Ensure we capture the post-highlight DOM (Shiki wraps tokens in spans).
208+
await waitFor(
209+
() => {
210+
const hasHighlightedSpans = container.querySelector(".code-line span");
211+
if (!hasHighlightedSpans) {
212+
throw new Error("Code block not highlighted yet");
213+
}
214+
},
215+
{ timeout: 5000 }
216+
);
217+
218+
const noLangLine = "65d02772b 🤖 feat: Settings-driven model selector with visibility controls";
219+
220+
const codeEl = await waitFor(
221+
() => {
222+
const candidates = Array.from(
223+
canvasElement.querySelectorAll(".markdown-content pre > code")
224+
);
225+
const found = candidates.find((el) => el.textContent?.includes(noLangLine));
226+
if (!found) {
227+
throw new Error("No-language code block not found");
228+
}
229+
return found;
230+
},
231+
{ timeout: 5000 }
232+
);
233+
234+
const style = window.getComputedStyle(codeEl);
235+
await expect(style.marginTop).toBe("0px");
236+
await expect(style.marginBottom).toBe("0px");
237+
// Regression: Shiki can emit a visually-empty trailing line (<span></span>), which would render
238+
// as a phantom extra line in our line-numbered code blocks.
239+
await expect(container.querySelectorAll(".line-number").length).toBe(1);
240+
},
163241
};

src/browser/styles/globals.css

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1414,7 +1414,7 @@ code {
14141414
}
14151415

14161416
.markdown-content pre {
1417-
background: rgba(0, 0, 0, 0.3);
1417+
background: var(--color-code-bg);
14181418
padding: 12px;
14191419
border-radius: 4px;
14201420
overflow-x: auto;
@@ -1424,6 +1424,7 @@ code {
14241424
.markdown-content pre code {
14251425
background: none;
14261426
padding: 0;
1427+
margin: 0;
14271428
color: var(--color-foreground);
14281429
}
14291430

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import { extractShikiLines } from "./shiki-shared";
4+
5+
describe("extractShikiLines", () => {
6+
test("removes trailing visually-empty Shiki line (e.g. <span></span>)", () => {
7+
const html = `<pre class="shiki"><code><span class="line"><span style="color:#fff">https://github.com/coder/mux/pull/new/chat-autocomplete-b24r</span></span>
8+
<span class="line"><span style="color:#fff"></span></span>
9+
</code></pre>`;
10+
11+
expect(extractShikiLines(html)).toEqual([
12+
`<span style="color:#fff">https://github.com/coder/mux/pull/new/chat-autocomplete-b24r</span>`,
13+
]);
14+
});
15+
});

src/browser/utils/highlighting/shiki-shared.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,19 @@ export function mapToShikiLang(detectedLang: string): string {
2424
* Extract line contents from Shiki HTML output
2525
* Shiki wraps code in <pre><code>...</code></pre> with <span class="line">...</span> per line
2626
*/
27+
function isVisuallyEmptyShikiLine(lineHtml: string): boolean {
28+
// Shiki represents an empty line as something like:
29+
// <span class="line"><span style="..."></span></span>
30+
// which is visually empty but non-empty as a string.
31+
//
32+
// We treat these as empty so callers don't render a phantom blank line.
33+
const textOnly = lineHtml
34+
.replace(/<[^>]*>/g, "")
35+
.replace(/&nbsp;/g, "")
36+
.trim();
37+
return textOnly === "";
38+
}
39+
2740
export function extractShikiLines(html: string): string[] {
2841
const codeMatch = /<code[^>]*>(.*?)<\/code>/s.exec(html);
2942
if (!codeMatch) return [];
@@ -35,10 +48,11 @@ export function extractShikiLines(html: string): string[] {
3548
const contentStart = start + '<span class="line">'.length;
3649
const end = chunk.lastIndexOf("</span>");
3750

38-
return end > contentStart ? chunk.substring(contentStart, end) : "";
51+
const lineHtml = end > contentStart ? chunk.substring(contentStart, end) : "";
52+
return isVisuallyEmptyShikiLine(lineHtml) ? "" : lineHtml;
3953
});
4054

41-
// Remove trailing empty lines (Shiki often adds one)
55+
// Remove trailing empty lines (Shiki often adds one).
4256
while (lines.length > 0 && lines[lines.length - 1] === "") {
4357
lines.pop();
4458
}

0 commit comments

Comments
 (0)