fix(codeSnapshot): preserve line structure for line numbers

This commit is contained in:
Melody Ruch authored and GitHub committed 2026-08-23 12:25:45 +08:00
1 parent 726f86e24c
commit 38664e6aea
4 files changed
+63 -3

No files matched your search

@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { createAiShikiBlockCodeHighlighter } from "@/lib/ai/aiCodeHighlighter";
describe("createAiShikiBlockCodeHighlighter", () => {
it("wraps every source line in its own .line span with no stray connecting newline", async () => {
const highlight = await createAiShikiBlockCodeHighlighter({ appearance: () => "light" });
const html = highlight("SELECT\n id,\n name\nFROM users", "sql");
expect(html.match(/class="line"/g)).toHaveLength(4);
// Issue #6894: shiki's classic structure joins lines with a literal "\n" text
// node; since `.line` is already `display:block`, leaving that in doubles the
// visual line spacing under `white-space:pre`. It must be stripped.
expect(html).not.toMatch(/<\/span>\n/);
// Leading indentation must survive (it's rendered as a plain text token).
expect(html).toContain(" id,");
expect(html).toContain(" name");
});
it("does not leak shiki's own <pre>/<code> wrapper (the caller supplies its own)", async () => {
const highlight = await createAiShikiBlockCodeHighlighter({ appearance: () => "dark" });
const html = highlight("SELECT 1", "sql");
expect(html).not.toContain("<pre");
expect(html).not.toContain("<code");
});
});
@@ -58,6 +58,38 @@ export async function createAiShikiCodeHighlighter(options: AiShikiCodeHighlight
});
}
/**
* Like `createAiShikiCodeHighlighter`, but keeps shiki's per-line `.line`
* wrappers (structure "classic") instead of flattening them away. Callers
* that lay out code as a block — one line per row, e.g. with CSS-counter
* line numbers — need this; `structure: "inline"` discards the `.line`
* elements entirely, so counters/line-based layout silently no-op.
*/
export async function createAiShikiBlockCodeHighlighter(options: AiShikiCodeHighlighterOptions): Promise<AiCodeHighlighter> {
const highlighter = await getAiShikiHighlighter();
return (content, lang, appearance = options.appearance()) => {
const html = highlighter.codeToHtml(content, {
lang: resolveShikiLanguage(lang),
structure: "classic",
theme: SHIKI_THEMES[appearance],
});
return extractShikiCodeInnerHtml(html);
};
}
/**
* Shiki's "classic" structure wraps lines in `<pre ...><code ...>...</code></pre>`;
* callers that supply their own `<pre>/<code>` shell only want what's inside.
* It also joins `<span class="line">` siblings with a literal "\n" text node —
* redundant once `.line{display:block}` already breaks each line, and doubles
* the visual line spacing under `white-space:pre` if left in.
*/
function extractShikiCodeInnerHtml(html: string): string {
const match = /<code[^>]*>([\s\S]*)<\/code>/.exec(html);
const inner = match ? match[1] : html;
return inner.replace(/<\/span>\n/g, "</span>");
}
function getAiShikiHighlighter(): Promise<ShikiHighlighter> {
highlighterPromise ??= loadAiShikiHighlighter();
return highlighterPromise;
@@ -14,7 +14,7 @@ const { createCodeHighlighter, highlightCode, toPng, isTauriRuntime, save, write
});
vi.mock("@/lib/ai/aiCodeHighlighter", () => ({
createAiShikiCodeHighlighter: createCodeHighlighter,
createAiShikiBlockCodeHighlighter: createCodeHighlighter,
}));
vi.mock("dom-to-image-more", () => ({
@@ -12,7 +12,7 @@
* clones the node into a foreignObject.
*/
import type { AppThemeAppearance } from "@/lib/app/appTheme";
import { createAiShikiCodeHighlighter, type AiCodeHighlighter } from "@/lib/ai/aiCodeHighlighter";
import { createAiShikiBlockCodeHighlighter, type AiCodeHighlighter } from "@/lib/ai/aiCodeHighlighter";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
export interface CodeSnapshotSource {
@@ -135,7 +135,7 @@ let highlighterPromise: Promise<AiCodeHighlighter> | undefined;
/** Lazy singleton reusing the AI assistant's shiki highlighter instance. */
export function getCodeSnapshotHighlighter(): Promise<AiCodeHighlighter> {
highlighterPromise ??= createAiShikiCodeHighlighter({
highlighterPromise ??= createAiShikiBlockCodeHighlighter({
appearance: () => "dark",
}).catch((err: unknown) => {
// Reset so a transient highlighter failure can be retried on the next