Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/test/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { gateway } from "@ai-sdk/gateway";
import { Chat } from "./components/chat";

// ISR: Generate on-demand, cache for 5 minutes
export const revalidate = 300;

export default async function Page() {
const { models } = await gateway.getAvailableModels();
const list = models
Expand Down
79 changes: 30 additions & 49 deletions apps/website/components/code-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ import {
createContext,
type HTMLAttributes,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { type BundledLanguage, codeToHtml } from "shiki";
import {
bundledLanguages,
type BundledLanguage,
type SpecialLanguage,
} from "shiki";
import { createJavaScriptRegexEngine, useShikiHighlighter } from "react-shiki";
import { cn } from "@/lib/utils";

type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
Expand All @@ -26,63 +29,39 @@ const CodeBlockContext = createContext<CodeBlockContextType>({
code: "",
});

export async function highlightCode(code: string, language: BundledLanguage) {
return Promise.all([
await codeToHtml(code, {
lang: language,
theme: "github-light",
}),
await codeToHtml(code, {
lang: language,
theme: "github-dark",
}),
]);
}

export const CodeBlock = ({
code,
language,
className,
children,
...props
}: CodeBlockProps) => {
const [html, setHtml] = useState<string>("");
const [darkHtml, setDarkHtml] = useState<string>("");
const mounted = useRef(false);
const isLanguageSupported = (lang: string): lang is BundledLanguage => {
return Object.hasOwn(bundledLanguages, lang);
};

useEffect(() => {
highlightCode(code, language).then(([light, dark]) => {
if (!mounted.current) {
setHtml(light);
setDarkHtml(dark);
mounted.current = true;
}
});
const langToUse = isLanguageSupported(language)
? language
: ("text" as SpecialLanguage);

return () => {
mounted.current = false;
};
}, [code, language]);
const html = useShikiHighlighter(
code,
langToUse,
{ light: "github-light", dark: "github-dark" },
{
outputFormat: "html",
defaultColor: "light-dark()",
engine: createJavaScriptRegexEngine({ forgiving: true }),
}
);

return (
<CodeBlockContext.Provider value={{ code }}>
<div className="group relative">
<div
className={cn(
"overflow-x-auto dark:hidden [&>pre]:bg-transparent!",
className
)}
className={cn("overflow-x-auto [&>pre]:bg-transparent!", className)}
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
dangerouslySetInnerHTML={{ __html: html }}
{...props}
/>
<div
className={cn(
"hidden overflow-x-auto dark:block [&>pre]:bg-transparent!",
className
)}
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
dangerouslySetInnerHTML={{ __html: darkHtml }}
dangerouslySetInnerHTML={{ __html: (html as string) || "" }}
{...props}
/>
{children}
Expand Down Expand Up @@ -115,10 +94,12 @@ export const CodeBlockCopyButton = ({
}

try {
await navigator.clipboard.writeText(code);
setIsCopied(true);
onCopy?.();
setTimeout(() => setIsCopied(false), timeout);
if (!isCopied) {
await navigator.clipboard.writeText(code);
setIsCopied(true);
onCopy?.();
setTimeout(() => setIsCopied(false), timeout);
}
} catch (error) {
onError?.(error as Error);
}
Expand Down
1 change: 1 addition & 0 deletions apps/website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"react": "19.1.1",
"react-dom": "19.1.1",
"react-markdown": "^10.1.0",
"react-shiki": "^0.9.0",
"rehype-harden": "^1.1.5",
"shiki": "^3.12.2",
"streamdown": "workspace:*",
Expand Down
208 changes: 208 additions & 0 deletions packages/streamdown/__tests__/code-block.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -313,4 +313,212 @@ describe("CodeBlock with multiple languages", () => {
{ timeout: 5000 }
);
});

it("should actually highlight code with syntax colors", async () => {
const jsCode = "const x = 1;";
const { container } = render(
<ShikiThemeContext.Provider value={["github-light", "github-dark"]}>
<CodeBlock code={jsCode} language="javascript" />
</ShikiThemeContext.Provider>
);

await waitFor(
() => {
// Check for shiki-themed pre element with CSS variables
const pre = container.querySelector("pre.shiki");
expect(pre).toBeTruthy();
expect(pre).toHaveClass("shiki-themes");

// Verify CSS variables for multi-theme support
const style = pre?.getAttribute("style");
expect(style).toBeTruthy();
expect(style).toContain("--shiki-light");
expect(style).toContain("--shiki-dark");

// Verify actual syntax highlighting tokens exist
// Code should be wrapped in span elements (tokens)
const tokens = container.querySelectorAll("pre.shiki span.line span");
expect(tokens.length).toBeGreaterThan(0);

// At least one token should have style attribute with color
const tokensWithStyle = Array.from(tokens).filter(
(token) => token.getAttribute("style")
);
expect(tokensWithStyle.length).toBeGreaterThan(0);
},
{ timeout: 5000 }
);
});

it("should remove background styles from pre element", async () => {
const { container } = render(
<ShikiThemeContext.Provider value={["github-light", "github-dark"]}>
<CodeBlock code="const x = 1;" language="javascript" />
</ShikiThemeContext.Provider>
);

await waitFor(
() => {
const pre = container.querySelector("pre.shiki");
expect(pre).toBeTruthy();

const style = pre?.getAttribute("style");
expect(style).toBeTruthy();

// Should NOT contain ANY background properties
expect(style).not.toMatch(/background[^;]*;?/);
expect(style).not.toMatch(/background-color/);
expect(style).not.toMatch(/background-image/);
expect(style).not.toMatch(/background:/);

// Should still have CSS variables for theming (proves we didn't remove all styles)
expect(style).toContain("--shiki-light");
expect(style).toContain("--shiki-dark");

// Verify it actually has syntax highlighting tokens
const tokens = pre.querySelectorAll("span.line span");
expect(tokens.length).toBeGreaterThan(0);
},
{ timeout: 5000 }
);
});

it("should apply preClassName to pre element", async () => {
const { container } = render(
<ShikiThemeContext.Provider value={["github-light", "github-dark"]}>
<CodeBlock
code="const x = 1;"
language="javascript"
preClassName="custom-pre-class"
/>
</ShikiThemeContext.Provider>
);

await waitFor(
() => {
const pre = container.querySelector("pre.shiki");
expect(pre).toBeTruthy();

// Verify the actual class attribute contains our custom class
const classAttr = pre?.getAttribute("class");
expect(classAttr).toContain("custom-pre-class");
expect(classAttr).toContain("shiki");

// Also use toHaveClass for convenience
expect(pre).toHaveClass("custom-pre-class");
expect(pre).toHaveClass("shiki");
expect(pre).toHaveClass("shiki-themes");

// Verify highlighting actually happened (not just an empty pre)
const code = pre?.querySelector("code");
expect(code).toBeTruthy();
expect(code?.textContent).toContain("const x = 1");
},
{ timeout: 5000 }
);
});

it("should handle preClassName with multiple existing classes", async () => {
const { container } = render(
<ShikiThemeContext.Provider value={["github-light", "github-dark"]}>
<CodeBlock
code="const x = 1;"
language="javascript"
preClassName="my-custom-class another-class"
/>
</ShikiThemeContext.Provider>
);

await waitFor(
() => {
const pre = container.querySelector("pre.shiki");
expect(pre).toBeTruthy();

// Should merge all classes
expect(pre?.className).toContain("shiki");
expect(pre?.className).toContain("my-custom-class another-class");
},
{ timeout: 5000 }
);
});

it("should work without preClassName (transformer handles undefined)", async () => {
const { container } = render(
<ShikiThemeContext.Provider value={["github-light", "github-dark"]}>
<CodeBlock
code="const x = 1;"
language="javascript"
// NO preClassName prop
/>
</ShikiThemeContext.Provider>
);

await waitFor(
() => {
const pre = container.querySelector("pre.shiki");
expect(pre).toBeTruthy();

// Should only have Shiki's classes
const classAttr = pre?.getAttribute("class");
expect(classAttr).toContain("shiki");
expect(classAttr).not.toContain("undefined");
expect(classAttr).not.toContain("null");

// Highlighting should still work
const tokens = pre?.querySelectorAll("span.line span");
expect(tokens.length).toBeGreaterThan(0);
},
{ timeout: 5000 }
);
});

it("should produce valid HTML with transformers applied (integration test)", async () => {
const { container } = render(
<ShikiThemeContext.Provider value={["github-light", "github-dark"]}>
<CodeBlock
code="function hello() { return 'world'; }"
language="javascript"
preClassName="test-class"
/>
</ShikiThemeContext.Provider>
);

await waitFor(
() => {
const pre = container.querySelector("pre");
expect(pre).toBeTruthy();

// Get actual rendered HTML
const preHTML = pre?.outerHTML;
expect(preHTML).toBeTruthy();

// Verify structure: pre > code > span.line > span (tokens)
expect(preHTML).toContain("<pre");
expect(preHTML).toContain("<code");
expect(preHTML).toContain('class="line"');

// Verify transformers worked
expect(preHTML).toContain("test-class"); // preClassName transformer
expect(preHTML).not.toContain("background:"); // background removal transformer
expect(preHTML).not.toContain("background-color:"); // background removal transformer

// Verify CSS variables are present (not removed by background transformer)
expect(preHTML).toContain("--shiki-light");
expect(preHTML).toContain("--shiki-dark");

// Verify actual syntax highlighting happened
expect(preHTML).toContain("function");
expect(preHTML).toContain("hello");
expect(preHTML).toContain("return");
expect(preHTML).toContain("world");

// Log for manual verification (can be removed in production)
console.log("\n=== INTEGRATION TEST: Rendered HTML ===");
console.log("Pre classes:", pre?.getAttribute("class"));
console.log("Pre style (first 200 chars):", pre?.getAttribute("style")?.substring(0, 200));
console.log("Token count:", pre?.querySelectorAll("span.line span").length);
},
{ timeout: 5000 }
);
});
});
Loading