diff --git a/.changeset/full-donuts-help.md b/.changeset/full-donuts-help.md new file mode 100644 index 00000000..f62cf082 --- /dev/null +++ b/.changeset/full-donuts-help.md @@ -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.` behavior as the default when not configured +- Expose the configuration through `StreamdownContext` without prop drilling diff --git a/packages/streamdown/__tests__/code-block-download.test.tsx b/packages/streamdown/__tests__/code-block-download.test.tsx index 74b2e860..874f4bd5 100644 --- a/packages/streamdown/__tests__/code-block-download.test.tsx +++ b/packages/streamdown/__tests__/code-block-download.test.tsx @@ -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( + + + + + + ); + + 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( + + + + + + ); + + 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( + + + + + + ); + + 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( + + + + + + ); + + 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( + + + + + + ); + + 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" + ); + }); }); diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx index be32d9fd..6a6b5dd7 100644 --- a/packages/streamdown/index.tsx +++ b/packages/streamdown/index.tsx @@ -182,6 +182,10 @@ export interface MermaidOptions { errorComponent?: React.ComponentType; } +export interface CodeDownloadConfig { + baseFileName?: string; +} + export type AllowedTags = Record; export type StreamdownProps = Options & { @@ -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 = { @@ -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 */ @@ -298,6 +304,7 @@ const defaultLinkSafetyConfig: LinkSafetyConfig = { }; const defaultStreamdownContext: StreamdownContextType = { + codeDownload: undefined, shikiTheme: defaultShikiTheme, controls: true, isAnimating: false, @@ -441,6 +448,7 @@ export const Streamdown = memo( shikiTheme = defaultShikiTheme, mermaid, controls = true, + codeDownload, isAnimating = false, animated, BlockComponent = Block, @@ -611,6 +619,7 @@ export const Streamdown = memo( () => ({ shikiTheme: plugins?.code?.getThemes() ?? shikiTheme, controls, + codeDownload, isAnimating, lineNumbers, mode, @@ -621,6 +630,7 @@ export const Streamdown = memo( shikiTheme, controls, isAnimating, + codeDownload, lineNumbers, mode, mermaid, diff --git a/packages/streamdown/lib/code-block/download-button.tsx b/packages/streamdown/lib/code-block/download-button.tsx index 4a315d1f..cc4a2b96 100644 --- a/packages/streamdown/lib/code-block/download-button.tsx +++ b/packages/streamdown/lib/code-block/download-button.tsx @@ -334,7 +334,7 @@ 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; @@ -342,7 +342,7 @@ export const CodeBlockDownloadButton = ({ language && language in languageExtensionMap ? languageExtensionMap[language] : "txt"; - const filename = `file.${extension}`; + const filename = `${codeDownload?.baseFileName ?? "file"}.${extension}`; const mimeType = "text/plain"; const downloadCode = () => {