fix(codeSnapshot): preserve line structure for line numbers
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user