Skip to content
Open
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
8 changes: 8 additions & 0 deletions .changeset/full-donuts-help.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"streamdown": minor
---

- Add `codeDownload.baseFileName` to customize downloaded code filenames
- Preserve automatic language-to-extension mapping for downloaded files
- Keep existing `file.<ext>` behavior as the default when not configured
- Expose the configuration through `StreamdownContext` without prop drilling
186 changes: 186 additions & 0 deletions packages/streamdown/__tests__/code-block-download.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,190 @@ describe("CodeBlockDownloadButton", () => {
);
expect(button?.hasAttribute("disabled")).toBe(true);
});

it("should use custom baseFileName from context", async () => {
const { save } = await import("../lib/utils");

const { container } = render(
<StreamdownContext.Provider
value={{
shikiTheme: ["github-light", "github-dark"],
controls: true,
isAnimating: false,
mode: "streaming",
codeDownload: { baseFileName: "myScript" },
}}
>
<CodeBlock code="console.log('test');" language="javascript">
<CodeBlockDownloadButton
code="console.log('test');"
language="javascript"
/>
</CodeBlock>
</StreamdownContext.Provider>
);

await waitFor(() => {
const button = container.querySelector(
'[data-streamdown="code-block-download-button"]'
);
expect(button?.hasAttribute("disabled")).toBe(false);
});

const button = container.querySelector(
'[data-streamdown="code-block-download-button"]'
);
// biome-ignore lint/style/noNonNullAssertion: test assertion
fireEvent.click(button!);

expect(save).toHaveBeenCalledWith(
"myScript.js",
"console.log('test');",
"text/plain"
);
});

it("should use custom baseFileName with unknown language", async () => {
const { save } = await import("../lib/utils");

const { container } = render(
<StreamdownContext.Provider
value={{
shikiTheme: ["github-light", "github-dark"],
controls: true,
isAnimating: false,
mode: "streaming",
codeDownload: { baseFileName: "output" },
}}
>
<CodeBlock code="some data" language="unknown">
<CodeBlockDownloadButton code="some data" language="unknown" />
</CodeBlock>
</StreamdownContext.Provider>
);

await waitFor(() => {
const button = container.querySelector(
'[data-streamdown="code-block-download-button"]'
);
expect(button?.hasAttribute("disabled")).toBe(false);
});

const button = container.querySelector(
'[data-streamdown="code-block-download-button"]'
);
// biome-ignore lint/style/noNonNullAssertion: test assertion
fireEvent.click(button!);

expect(save).toHaveBeenCalledWith("output.txt", "some data", "text/plain");
});

it("should fall back to default filename when codeDownload is undefined", async () => {
const { save } = await import("../lib/utils");

const { container } = render(
<StreamdownContext.Provider
value={{
shikiTheme: ["github-light", "github-dark"],
controls: true,
isAnimating: false,
mode: "streaming",
codeDownload: undefined,
}}
>
<CodeBlock code="python code" language="python">
<CodeBlockDownloadButton code="python code" language="python" />
</CodeBlock>
</StreamdownContext.Provider>
);

await waitFor(() => {
const button = container.querySelector(
'[data-streamdown="code-block-download-button"]'
);
expect(button?.hasAttribute("disabled")).toBe(false);
});

const button = container.querySelector(
'[data-streamdown="code-block-download-button"]'
);
// biome-ignore lint/style/noNonNullAssertion: test assertion
fireEvent.click(button!);

expect(save).toHaveBeenCalledWith("file.py", "python code", "text/plain");
});

it("should fall back to default filename when baseFileName is not set", async () => {
const { save } = await import("../lib/utils");

const { container } = render(
<StreamdownContext.Provider
value={{
shikiTheme: ["github-light", "github-dark"],
controls: true,
isAnimating: false,
mode: "streaming",
codeDownload: {},
}}
>
<CodeBlock code="rust code" language="rust">
<CodeBlockDownloadButton code="rust code" language="rust" />
</CodeBlock>
</StreamdownContext.Provider>
);

await waitFor(() => {
const button = container.querySelector(
'[data-streamdown="code-block-download-button"]'
);
expect(button?.hasAttribute("disabled")).toBe(false);
});

const button = container.querySelector(
'[data-streamdown="code-block-download-button"]'
);
// biome-ignore lint/style/noNonNullAssertion: test assertion
fireEvent.click(button!);

expect(save).toHaveBeenCalledWith("file.rs", "rust code", "text/plain");
});

it("should handle special characters in custom baseFileName", async () => {
const { save } = await import("../lib/utils");

const { container } = render(
<StreamdownContext.Provider
value={{
shikiTheme: ["github-light", "github-dark"],
controls: true,
isAnimating: false,
mode: "streaming",
codeDownload: { baseFileName: "my-config.backup" },
}}
>
<CodeBlock code="config data" language="json">
<CodeBlockDownloadButton code="config data" language="json" />
</CodeBlock>
</StreamdownContext.Provider>
);

await waitFor(() => {
const button = container.querySelector(
'[data-streamdown="code-block-download-button"]'
);
expect(button?.hasAttribute("disabled")).toBe(false);
});

const button = container.querySelector(
'[data-streamdown="code-block-download-button"]'
);
// biome-ignore lint/style/noNonNullAssertion: test assertion
fireEvent.click(button!);

expect(save).toHaveBeenCalledWith(
"my-config.backup.json",
"config data",
"text/plain"
);
});
});
10 changes: 10 additions & 0 deletions packages/streamdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,10 @@ export interface MermaidOptions {
errorComponent?: React.ComponentType<MermaidErrorComponentProps>;
}

export interface CodeDownloadConfig {
baseFileName?: string;
}

export type AllowedTags = Record<string, string[]>;

export type StreamdownProps = Options & {
Expand Down Expand Up @@ -233,6 +237,7 @@ export type StreamdownProps = Options & {
onAnimationStart?: () => void;
/** Called when isAnimating transitions from true to false. Suppressed in mode="static". */
onAnimationEnd?: () => void;
codeDownload?: CodeDownloadConfig;
};

const defaultSanitizeSchema = {
Expand Down Expand Up @@ -278,6 +283,7 @@ const carets = {

// Combined context for better performance - reduces React tree depth from 5 nested providers to 1
export interface StreamdownContextType {
codeDownload?: CodeDownloadConfig;
controls: ControlsConfig;
isAnimating: boolean;
/** Show line numbers in code blocks. @default true */
Expand All @@ -298,6 +304,7 @@ const defaultLinkSafetyConfig: LinkSafetyConfig = {
};

const defaultStreamdownContext: StreamdownContextType = {
codeDownload: undefined,
shikiTheme: defaultShikiTheme,
controls: true,
isAnimating: false,
Expand Down Expand Up @@ -441,6 +448,7 @@ export const Streamdown = memo(
shikiTheme = defaultShikiTheme,
mermaid,
controls = true,
codeDownload,
isAnimating = false,
animated,
BlockComponent = Block,
Expand Down Expand Up @@ -611,6 +619,7 @@ export const Streamdown = memo(
() => ({
shikiTheme: plugins?.code?.getThemes() ?? shikiTheme,
controls,
codeDownload,
isAnimating,
lineNumbers,
mode,
Expand All @@ -621,6 +630,7 @@ export const Streamdown = memo(
shikiTheme,
controls,
isAnimating,
codeDownload,
lineNumbers,
mode,
mermaid,
Expand Down
4 changes: 2 additions & 2 deletions packages/streamdown/lib/code-block/download-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -334,15 +334,15 @@ export const CodeBlockDownloadButton = ({
}) => {
const cn = useCn();
const { code: contextCode } = useCodeBlockContext();
const { isAnimating } = useContext(StreamdownContext);
const { isAnimating, codeDownload } = useContext(StreamdownContext);
const t = useTranslations();
const icons = useIcons();
const code = propCode ?? contextCode;
const extension =
language && language in languageExtensionMap
? languageExtensionMap[language]
: "txt";
const filename = `file.${extension}`;
const filename = `${codeDownload?.baseFileName ?? "file"}.${extension}`;
const mimeType = "text/plain";

const downloadCode = () => {
Expand Down
Loading