From 28e8710267871e938f0ea9798cb8353b0258909a Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Wed, 15 Jul 2026 10:16:01 +0530 Subject: [PATCH 01/12] feat(streamdown): add code download configuration to StreamdownProps and context - Introduced CodeDownloadConfig interface to define optional baseFileName for code downloads. - Updated StreamdownProps to include codeDownload property. - Enhanced StreamdownContextType to support codeDownload configuration. - Ensured default values are set for new properties in the context. --- packages/streamdown/index.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) 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, From 638cbac466f863fadca9f2723feff4234c3a1a11 Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Wed, 15 Jul 2026 10:16:07 +0530 Subject: [PATCH 02/12] fix(streamdown): update filename generation in download button to use codeDownload configuration - Modified filename logic in CodeBlockDownloadButton to utilize baseFileName from StreamdownContext. - Ensured fallback to default filename if baseFileName is not provided. --- packages/streamdown/lib/code-block/download-button.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 = () => { From 441cbc33878862aac2e948e2239fe74353d97cfa Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Wed, 15 Jul 2026 10:31:33 +0530 Subject: [PATCH 03/12] test(streamdown): enhance CodeBlockDownloadButton tests for custom filename scenarios - Added tests to verify the functionality of custom baseFileName in the download button. - Included cases for handling undefined codeDownload, unknown languages, and special characters in filenames. - Ensured that the button is enabled and the correct filename is used during the download process. --- .../__tests__/code-block-download.test.tsx | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) diff --git a/packages/streamdown/__tests__/code-block-download.test.tsx b/packages/streamdown/__tests__/code-block-download.test.tsx index 74b2e860..96efd9f1 100644 --- a/packages/streamdown/__tests__/code-block-download.test.tsx +++ b/packages/streamdown/__tests__/code-block-download.test.tsx @@ -137,4 +137,214 @@ 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" + ); + }); }); From 0c21d53dff68b699b7a284459bd584f0b0c20b76 Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Wed, 15 Jul 2026 10:32:04 +0530 Subject: [PATCH 04/12] chore: add changeset for configurable code download filenames --- .changeset/full-donuts-help.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/full-donuts-help.md 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 From 9259126b314d6f6b1c40e5860cb1e93833087300 Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Wed, 15 Jul 2026 10:43:45 +0530 Subject: [PATCH 05/12] refactor(tests): streamline CodeBlockDownloadButton test assertions and component usage - Simplified the rendering of CodeBlockDownloadButton by consolidating props into a single line. - Updated test assertions for expected save calls to improve readability and maintainability. --- .../__tests__/code-block-download.test.tsx | 38 ++++--------------- 1 file changed, 7 insertions(+), 31 deletions(-) diff --git a/packages/streamdown/__tests__/code-block-download.test.tsx b/packages/streamdown/__tests__/code-block-download.test.tsx index 96efd9f1..874f4bd5 100644 --- a/packages/streamdown/__tests__/code-block-download.test.tsx +++ b/packages/streamdown/__tests__/code-block-download.test.tsx @@ -194,10 +194,7 @@ describe("CodeBlockDownloadButton", () => { }} > - + ); @@ -215,11 +212,7 @@ describe("CodeBlockDownloadButton", () => { // biome-ignore lint/style/noNonNullAssertion: test assertion fireEvent.click(button!); - expect(save).toHaveBeenCalledWith( - "output.txt", - "some data", - "text/plain" - ); + expect(save).toHaveBeenCalledWith("output.txt", "some data", "text/plain"); }); it("should fall back to default filename when codeDownload is undefined", async () => { @@ -236,10 +229,7 @@ describe("CodeBlockDownloadButton", () => { }} > - + ); @@ -257,11 +247,7 @@ describe("CodeBlockDownloadButton", () => { // biome-ignore lint/style/noNonNullAssertion: test assertion fireEvent.click(button!); - expect(save).toHaveBeenCalledWith( - "file.py", - "python code", - "text/plain" - ); + expect(save).toHaveBeenCalledWith("file.py", "python code", "text/plain"); }); it("should fall back to default filename when baseFileName is not set", async () => { @@ -278,10 +264,7 @@ describe("CodeBlockDownloadButton", () => { }} > - + ); @@ -299,11 +282,7 @@ describe("CodeBlockDownloadButton", () => { // biome-ignore lint/style/noNonNullAssertion: test assertion fireEvent.click(button!); - expect(save).toHaveBeenCalledWith( - "file.rs", - "rust code", - "text/plain" - ); + expect(save).toHaveBeenCalledWith("file.rs", "rust code", "text/plain"); }); it("should handle special characters in custom baseFileName", async () => { @@ -320,10 +299,7 @@ describe("CodeBlockDownloadButton", () => { }} > - + ); From b544d08e1a9c3515a641e55ebba34e1e74a897c0 Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Fri, 21 Aug 2026 19:15:22 +0530 Subject: [PATCH 06/12] refactor(streamdown): update download configuration types in StreamdownProps - Introduced DownloadControlConfig type to enhance download configuration options. - Updated ControlsConfig to utilize DownloadControlConfig for download properties. - Removed deprecated codeDownload property from StreamdownProps and context for cleaner API. --- packages/streamdown/index.tsx | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx index 6a6b5dd7..43c678b5 100644 --- a/packages/streamdown/index.tsx +++ b/packages/streamdown/index.tsx @@ -132,6 +132,8 @@ export const normalizeHtmlIndentation = (content: string): string => { return content.replace(HTML_LINE_INDENT_PATTERN, "$1"); }; +export type DownloadControlConfig = boolean | { filename: string }; + export type ControlsConfig = | boolean | { @@ -139,19 +141,19 @@ export type ControlsConfig = | boolean | { copy?: boolean; - download?: boolean; + download?: DownloadControlConfig; fullscreen?: boolean; }; code?: | boolean | { copy?: boolean; - download?: boolean; + download?: DownloadControlConfig; }; mermaid?: | boolean | { - download?: boolean; + download?: DownloadControlConfig; copy?: boolean; fullscreen?: boolean; panZoom?: boolean; @@ -182,10 +184,6 @@ export interface MermaidOptions { errorComponent?: React.ComponentType; } -export interface CodeDownloadConfig { - baseFileName?: string; -} - export type AllowedTags = Record; export type StreamdownProps = Options & { @@ -237,7 +235,6 @@ export type StreamdownProps = Options & { onAnimationStart?: () => void; /** Called when isAnimating transitions from true to false. Suppressed in mode="static". */ onAnimationEnd?: () => void; - codeDownload?: CodeDownloadConfig; }; const defaultSanitizeSchema = { @@ -283,7 +280,6 @@ 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 */ @@ -304,7 +300,6 @@ const defaultLinkSafetyConfig: LinkSafetyConfig = { }; const defaultStreamdownContext: StreamdownContextType = { - codeDownload: undefined, shikiTheme: defaultShikiTheme, controls: true, isAnimating: false, @@ -448,7 +443,6 @@ export const Streamdown = memo( shikiTheme = defaultShikiTheme, mermaid, controls = true, - codeDownload, isAnimating = false, animated, BlockComponent = Block, @@ -619,7 +613,6 @@ export const Streamdown = memo( () => ({ shikiTheme: plugins?.code?.getThemes() ?? shikiTheme, controls, - codeDownload, isAnimating, lineNumbers, mode, @@ -630,7 +623,6 @@ export const Streamdown = memo( shikiTheme, controls, isAnimating, - codeDownload, lineNumbers, mode, mermaid, From 0c4608551226a3d02879cae6017764513aa48771 Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Fri, 21 Aug 2026 19:22:22 +0530 Subject: [PATCH 07/12] feat(streamdown): implement dynamic filename generation for downloads - Added a new utility function `getDownloadFilename` to retrieve configurable download filenames based on the provided controls configuration. - Updated `CodeBlockDownloadButton`, `MermaidDownloadDropdown`, and `TableDownloadButton` components to utilize the new filename generation logic, enhancing flexibility for download filenames. - Ensured fallback options are in place for scenarios where configuration is not defined. --- .../lib/code-block/download-button.tsx | 5 ++-- packages/streamdown/lib/controls.ts | 23 +++++++++++++++++++ .../lib/mermaid/download-button.tsx | 10 ++++---- .../lib/table/download-dropdown.tsx | 15 ++++++++---- 4 files changed, 42 insertions(+), 11 deletions(-) create mode 100644 packages/streamdown/lib/controls.ts diff --git a/packages/streamdown/lib/code-block/download-button.tsx b/packages/streamdown/lib/code-block/download-button.tsx index cc4a2b96..9910fbda 100644 --- a/packages/streamdown/lib/code-block/download-button.tsx +++ b/packages/streamdown/lib/code-block/download-button.tsx @@ -1,5 +1,6 @@ import { type ComponentProps, useContext } from "react"; import { StreamdownContext } from "../../index"; +import { getDownloadFilename } from "../controls"; import { useIcons } from "../icon-context"; import { useCn } from "../prefix-context"; import { useTranslations } from "../translations-context"; @@ -334,7 +335,7 @@ export const CodeBlockDownloadButton = ({ }) => { const cn = useCn(); const { code: contextCode } = useCodeBlockContext(); - const { isAnimating, codeDownload } = useContext(StreamdownContext); + const { isAnimating, controls } = useContext(StreamdownContext); const t = useTranslations(); const icons = useIcons(); const code = propCode ?? contextCode; @@ -342,7 +343,7 @@ export const CodeBlockDownloadButton = ({ language && language in languageExtensionMap ? languageExtensionMap[language] : "txt"; - const filename = `${codeDownload?.baseFileName ?? "file"}.${extension}`; + const filename = `${getDownloadFilename(controls, "code", "file")}.${extension}`; const mimeType = "text/plain"; const downloadCode = () => { diff --git a/packages/streamdown/lib/controls.ts b/packages/streamdown/lib/controls.ts new file mode 100644 index 00000000..4918663a --- /dev/null +++ b/packages/streamdown/lib/controls.ts @@ -0,0 +1,23 @@ +import type { ControlsConfig } from "../index"; + +export const getDownloadFilename = ( + config: ControlsConfig, + type: "code" | "table" | "mermaid", + fallback: string +): string => { + if (typeof config === "boolean") { + return fallback; + } + + const typeConfig = config[type]; + if (typeof typeConfig !== "object") { + return fallback; + } + + const downloadConfig = typeConfig.download; + if (typeof downloadConfig !== "object") { + return fallback; + } + + return downloadConfig.filename || fallback; +}; diff --git a/packages/streamdown/lib/mermaid/download-button.tsx b/packages/streamdown/lib/mermaid/download-button.tsx index 7f287f2d..698f396e 100644 --- a/packages/streamdown/lib/mermaid/download-button.tsx +++ b/packages/streamdown/lib/mermaid/download-button.tsx @@ -1,6 +1,7 @@ import type { MermaidConfig } from "mermaid"; import { useContext, useEffect, useRef, useState } from "react"; import { StreamdownContext } from "../../index"; +import { getDownloadFilename } from "../controls"; import { useIcons } from "../icon-context"; import { useMermaidPlugin } from "../plugin-context"; import { useCn } from "../prefix-context"; @@ -28,16 +29,17 @@ export const MermaidDownloadDropdown = ({ const cn = useCn(); const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); - const { isAnimating } = useContext(StreamdownContext); + const { isAnimating, controls } = useContext(StreamdownContext); const icons = useIcons(); const mermaidPlugin = useMermaidPlugin(); const t = useTranslations(); + const baseFilename = getDownloadFilename(controls, "mermaid", "diagram"); const downloadMermaid = async (format: "mmd" | "png" | "svg") => { try { if (format === "mmd") { // Download as Mermaid source code - const filename = "diagram.mmd"; + const filename = `${baseFilename}.mmd`; const mimeType = "text/plain"; save(filename, chart, mimeType); setIsOpen(false); @@ -70,7 +72,7 @@ export const MermaidDownloadDropdown = ({ } if (format === "svg") { - const filename = "diagram.svg"; + const filename = `${baseFilename}.svg`; const mimeType = "image/svg+xml"; save(filename, svg, mimeType); setIsOpen(false); @@ -80,7 +82,7 @@ export const MermaidDownloadDropdown = ({ if (format === "png") { const blob = await svgToPngBlob(svg); - save("diagram.png", blob, "image/png"); + save(`${baseFilename}.png`, blob, "image/png"); onDownload?.(format); setIsOpen(false); return; diff --git a/packages/streamdown/lib/table/download-dropdown.tsx b/packages/streamdown/lib/table/download-dropdown.tsx index ead50cf8..6366ce80 100644 --- a/packages/streamdown/lib/table/download-dropdown.tsx +++ b/packages/streamdown/lib/table/download-dropdown.tsx @@ -1,5 +1,6 @@ import { useContext, useEffect, useRef, useState } from "react"; import { StreamdownContext } from "../../index"; +import { getDownloadFilename } from "../controls"; import { useIcons } from "../icon-context"; import { useCn } from "../prefix-context"; import { useTranslations } from "../translations-context"; @@ -28,7 +29,7 @@ export const TableDownloadButton = ({ filename, }: TableDownloadButtonProps) => { const cn = useCn(); - const { isAnimating } = useContext(StreamdownContext); + const { isAnimating, controls } = useContext(StreamdownContext); const t = useTranslations(); const icons = useIcons(); @@ -68,7 +69,11 @@ export const TableDownloadButton = ({ extension = "csv"; } - save(`${filename || "table"}.${extension}`, content, mimeType); + save( + `${filename || getDownloadFilename(controls, "table", "table")}.${extension}`, + content, + mimeType + ); onDownload?.(); } catch (error) { @@ -110,7 +115,7 @@ export const TableDownloadDropdown = ({ const cn = useCn(); const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); - const { isAnimating } = useContext(StreamdownContext); + const { isAnimating, controls } = useContext(StreamdownContext); const t = useTranslations(); const icons = useIcons(); @@ -134,10 +139,10 @@ export const TableDownloadDropdown = ({ ? tableDataToCSV(tableData) : tableDataToMarkdown(tableData); const extension = format === "csv" ? "csv" : "md"; - const filename = `table.${extension}`; + const downloadFilename = `${getDownloadFilename(controls, "table", "table")}.${extension}`; const mimeType = format === "csv" ? "text/csv" : "text/markdown"; - save(filename, content, mimeType); + save(downloadFilename, content, mimeType); setIsOpen(false); onDownload?.(format); } catch (error) { From 1dc3f93511d67055bb7f2b985a35b0ef516a7098 Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Fri, 21 Aug 2026 19:23:24 +0530 Subject: [PATCH 08/12] test(streamdown): enhance tests for custom filename configurations in download components - Updated tests for `CodeBlockDownloadButton`, `MermaidDownloadDropdown`, and `TableDownloadButton` to verify the use of custom filenames from controls. - Added new test cases to ensure correct behavior when filenames are configured or omitted, including scenarios for fallback options. - Improved assertions for clarity and maintainability across the test suite. --- .../__tests__/code-block-download.test.tsx | 29 ++++---- .../streamdown/__tests__/controls.test.ts | 66 +++++++++++++++++ .../__tests__/mermaid-download.test.tsx | 73 ++++++++++++++++++- .../__tests__/show-controls.test.tsx | 28 +++++++ .../__tests__/table-dropdowns.test.tsx | 59 ++++++++++++++- 5 files changed, 236 insertions(+), 19 deletions(-) create mode 100644 packages/streamdown/__tests__/controls.test.ts diff --git a/packages/streamdown/__tests__/code-block-download.test.tsx b/packages/streamdown/__tests__/code-block-download.test.tsx index 874f4bd5..c2736ddb 100644 --- a/packages/streamdown/__tests__/code-block-download.test.tsx +++ b/packages/streamdown/__tests__/code-block-download.test.tsx @@ -138,17 +138,18 @@ describe("CodeBlockDownloadButton", () => { expect(button?.hasAttribute("disabled")).toBe(true); }); - it("should use custom baseFileName from context", async () => { + it("should use custom filename from controls", async () => { const { save } = await import("../lib/utils"); const { container } = render( @@ -180,17 +181,18 @@ describe("CodeBlockDownloadButton", () => { ); }); - it("should use custom baseFileName with unknown language", async () => { + it("should use custom filename with unknown language", async () => { const { save } = await import("../lib/utils"); const { container } = render( @@ -215,7 +217,7 @@ describe("CodeBlockDownloadButton", () => { expect(save).toHaveBeenCalledWith("output.txt", "some data", "text/plain"); }); - it("should fall back to default filename when codeDownload is undefined", async () => { + it("should fall back to default filename when controls is true", async () => { const { save } = await import("../lib/utils"); const { container } = render( @@ -225,7 +227,6 @@ describe("CodeBlockDownloadButton", () => { controls: true, isAnimating: false, mode: "streaming", - codeDownload: undefined, }} > @@ -250,17 +251,16 @@ describe("CodeBlockDownloadButton", () => { expect(save).toHaveBeenCalledWith("file.py", "python code", "text/plain"); }); - it("should fall back to default filename when baseFileName is not set", async () => { + it("should fall back to default filename when download is enabled without a filename", async () => { const { save } = await import("../lib/utils"); const { container } = render( @@ -285,17 +285,18 @@ describe("CodeBlockDownloadButton", () => { expect(save).toHaveBeenCalledWith("file.rs", "rust code", "text/plain"); }); - it("should handle special characters in custom baseFileName", async () => { + it("should handle special characters in custom filename", async () => { const { save } = await import("../lib/utils"); const { container } = render( diff --git a/packages/streamdown/__tests__/controls.test.ts b/packages/streamdown/__tests__/controls.test.ts new file mode 100644 index 00000000..0211b4b6 --- /dev/null +++ b/packages/streamdown/__tests__/controls.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { getDownloadFilename } from "../lib/controls"; + +describe("getDownloadFilename", () => { + it("returns the fallback when controls is a boolean", () => { + expect(getDownloadFilename(true, "code", "file")).toBe("file"); + expect(getDownloadFilename(false, "table", "table")).toBe("table"); + }); + + it("returns the fallback when the block type is not configured", () => { + expect(getDownloadFilename({}, "code", "file")).toBe("file"); + expect(getDownloadFilename({ table: true }, "code", "file")).toBe("file"); + }); + + it("returns the fallback when the block type is a boolean", () => { + expect(getDownloadFilename({ mermaid: true }, "mermaid", "diagram")).toBe( + "diagram" + ); + expect(getDownloadFilename({ mermaid: false }, "mermaid", "diagram")).toBe( + "diagram" + ); + }); + + it("returns the fallback when download is a boolean", () => { + expect( + getDownloadFilename({ code: { download: true } }, "code", "file") + ).toBe("file"); + expect( + getDownloadFilename({ table: { download: false } }, "table", "table") + ).toBe("table"); + }); + + it("returns the custom filename when download is configured", () => { + expect( + getDownloadFilename( + { code: { download: { filename: "myScript" } } }, + "code", + "file" + ) + ).toBe("myScript"); + expect( + getDownloadFilename( + { table: { download: { filename: "report" } } }, + "table", + "table" + ) + ).toBe("report"); + expect( + getDownloadFilename( + { mermaid: { download: { filename: "flowchart" } } }, + "mermaid", + "diagram" + ) + ).toBe("flowchart"); + }); + + it("returns the fallback when filename is empty", () => { + expect( + getDownloadFilename( + { code: { download: { filename: "" } } }, + "code", + "file" + ) + ).toBe("file"); + }); +}); diff --git a/packages/streamdown/__tests__/mermaid-download.test.tsx b/packages/streamdown/__tests__/mermaid-download.test.tsx index c4324626..ed173f8d 100644 --- a/packages/streamdown/__tests__/mermaid-download.test.tsx +++ b/packages/streamdown/__tests__/mermaid-download.test.tsx @@ -44,11 +44,12 @@ describe("MermaidDownloadDropdown", () => { const renderWithContext = ( props: any, - plugin: DiagramPlugin = createMockPlugin() + plugin: DiagramPlugin = createMockPlugin(), + context = defaultContext ) => { return render( - + @@ -290,4 +291,72 @@ describe("MermaidDownloadDropdown", () => { const button = container.querySelector("button"); expect(button?.hasAttribute("disabled")).toBe(true); }); + + it("should use custom filename from controls for mmd downloads", async () => { + const { save } = await import("../lib/utils"); + const onDownload = vi.fn(); + const { container } = renderWithContext( + { + chart: "graph TD; A-->B", + onDownload, + }, + createMockPlugin(), + { + ...defaultContext, + controls: { mermaid: { download: { filename: "flowchart" } } }, + } + ); + + // biome-ignore lint/style/noNonNullAssertion: test assertion + fireEvent.click(container.querySelector("button")!); + + const mmdButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent === "MMD" + ); + // biome-ignore lint/style/noNonNullAssertion: test assertion + fireEvent.click(mmdButton!); + + await waitFor(() => { + expect(save).toHaveBeenCalledWith( + "flowchart.mmd", + "graph TD; A-->B", + "text/plain" + ); + expect(onDownload).toHaveBeenCalledWith("mmd"); + }); + }); + + it("should use custom filename from controls for svg downloads", async () => { + const { save } = await import("../lib/utils"); + const onDownload = vi.fn(); + const { container } = renderWithContext( + { + chart: "graph TD; A-->B", + onDownload, + }, + createMockPlugin(), + { + ...defaultContext, + controls: { mermaid: { download: { filename: "flowchart" } } }, + } + ); + + // biome-ignore lint/style/noNonNullAssertion: test assertion + fireEvent.click(container.querySelector("button")!); + + const svgButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent === "SVG" + ); + // biome-ignore lint/style/noNonNullAssertion: test assertion + fireEvent.click(svgButton!); + + await waitFor(() => { + expect(save).toHaveBeenCalledWith( + "flowchart.svg", + expect.any(String), + "image/svg+xml" + ); + expect(onDownload).toHaveBeenCalledWith("svg"); + }); + }); }); diff --git a/packages/streamdown/__tests__/show-controls.test.tsx b/packages/streamdown/__tests__/show-controls.test.tsx index eda3fedc..46b1e2c1 100644 --- a/packages/streamdown/__tests__/show-controls.test.tsx +++ b/packages/streamdown/__tests__/show-controls.test.tsx @@ -351,6 +351,19 @@ graph TD expect(downloadBtn).toBeTruthy(); }); + it("should show download when table.download is a filename config", () => { + const { container } = render( + + {markdownWithTable} + + ); + + const downloadBtn = container.querySelector( + 'button[title="Download table"]' + ); + expect(downloadBtn).toBeTruthy(); + }); + it("should hide all table controls when no sub-controls are visible", () => { const { container } = render( { + const { container } = render( + + {markdownWithCode} + + ); + + await waitFor(() => { + const downloadBtn = container.querySelector( + 'button[title="Download file"]' + ); + expect(downloadBtn).toBeTruthy(); + }); + }); }); describe("with custom components", () => { diff --git a/packages/streamdown/__tests__/table-dropdowns.test.tsx b/packages/streamdown/__tests__/table-dropdowns.test.tsx index ec1e47d8..6f42d057 100644 --- a/packages/streamdown/__tests__/table-dropdowns.test.tsx +++ b/packages/streamdown/__tests__/table-dropdowns.test.tsx @@ -1,6 +1,6 @@ import { act, fireEvent, render } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { StreamdownContext } from "../index"; +import { type ControlsConfig, StreamdownContext } from "../index"; import { TableCopyDropdown } from "../lib/table/copy-dropdown"; import { TableDownloadButton, @@ -15,12 +15,15 @@ vi.mock("../lib/utils", async () => { }; }); -const renderInTableWrapper = (ui: React.ReactElement) => { +const renderInTableWrapper = ( + ui: React.ReactElement, + controls: ControlsConfig = true +) => { return render( { expect(onDownload).toHaveBeenCalledWith("markdown"); }); + it("should use custom filename from controls", async () => { + const { save } = await import("../lib/utils"); + const onDownload = vi.fn(); + + const { container } = renderInTableWrapper( + , + { table: { download: { filename: "report" } } } + ); + + const toggleBtn = container.querySelector('button[title="Download table"]'); + // biome-ignore lint/style/noNonNullAssertion: test assertion + fireEvent.click(toggleBtn!); + + const csvBtn = container.querySelector( + 'button[title="Download table as CSV"]' + ); + // biome-ignore lint/style/noNonNullAssertion: test assertion + fireEvent.click(csvBtn!); + + expect(save).toHaveBeenCalledWith( + "report.csv", + expect.any(String), + "text/csv" + ); + expect(onDownload).toHaveBeenCalledWith("csv"); + }); + it("should call onError when save throws", async () => { const { save } = await import("../lib/utils"); (save as any).mockImplementation(() => { @@ -210,6 +240,29 @@ describe("TableDownloadButton with format='markdown'", () => { expect(onDownload).toHaveBeenCalled(); }); + it("should use custom filename from controls when filename prop is omitted", async () => { + const { save } = await import("../lib/utils"); + const onDownload = vi.fn(); + + const { container } = renderInTableWrapper( + , + { table: { download: { filename: "export" } } } + ); + + const btn = container.querySelector( + 'button[title="Download table as CSV"]' + ); + // biome-ignore lint/style/noNonNullAssertion: test assertion + fireEvent.click(btn!); + + expect(save).toHaveBeenCalledWith( + "export.csv", + expect.any(String), + "text/csv" + ); + expect(onDownload).toHaveBeenCalled(); + }); + it("should handle default format (fallback to csv)", () => { const { container } = renderInTableWrapper( From 9acb972def5ac8fa662c1566f63e5e7fcdaa7985 Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Fri, 21 Aug 2026 19:24:06 +0530 Subject: [PATCH 09/12] docs: enhance documentation for custom download filenames in Streamdown - Added sections on setting custom download filenames for code blocks, tables, and mermaid diagrams across multiple documentation files. - Updated descriptions to clarify the use of the `download: { filename }` configuration option. - Provided code examples demonstrating how to implement custom filenames in the `Streamdown` component. --- apps/website/content/docs/code-blocks.mdx | 16 +++++++++++ apps/website/content/docs/configuration.mdx | 13 +++++---- apps/website/content/docs/gfm.mdx | 10 +++++++ apps/website/content/docs/interactivity.mdx | 28 +++++++++++++++++-- apps/website/content/docs/plugins/mermaid.mdx | 4 +-- 5 files changed, 61 insertions(+), 10 deletions(-) diff --git a/apps/website/content/docs/code-blocks.mdx b/apps/website/content/docs/code-blocks.mdx index f339f484..0154bbb5 100644 --- a/apps/website/content/docs/code-blocks.mdx +++ b/apps/website/content/docs/code-blocks.mdx @@ -236,6 +236,22 @@ Disable individual code block buttons using the `controls` prop: {markdown} ``` +### Custom Download Filename + +Pass `download: { filename }` to set a custom base name. Streamdown appends the language-appropriate extension automatically (for example `myScript.ts`). The default is `file.`. + +```tsx title="app/page.tsx" + + {markdown} + +``` + ## Inline Code Inline code uses backticks and receives subtle styling: diff --git a/apps/website/content/docs/configuration.mdx b/apps/website/content/docs/configuration.mdx index d87f0b25..fea8dc64 100644 --- a/apps/website/content/docs/configuration.mdx +++ b/apps/website/content/docs/configuration.mdx @@ -130,7 +130,8 @@ Math rendering and CJK support require installing separate plugins. See [Mathema type: "MermaidOptions", }, controls: { - description: "Control visibility of interactive buttons", + description: + "Control visibility of interactive buttons and custom download filenames for code, tables, and mermaid diagrams.", type: "ControlsConfig", default: "true", }, @@ -302,22 +303,22 @@ import { Streamdown, defaultUrlTransform } from 'streamdown'; }} /> -The `controls` prop can be configured granularly: +The `controls` prop can be configured granularly. Set a block type to `false` to hide all of its buttons, or pass an object to toggle individual actions. For downloads, pass `{ filename: "customName" }` to set a custom base filename — the file extension is added automatically. ```tsx title="app/page.tsx" ``` +You can still use `download: true` (or omit it) to keep the default filenames: `file.` for code, `table.csv` / `table.md` for tables, and `diagram.svg` / `diagram.png` / `diagram.mmd` for mermaid. + ### Remend Options The `remend` prop configures which Markdown completions are performed during streaming. All options default to `true` when not specified. Set an option to `false` to disable that completion: diff --git a/apps/website/content/docs/gfm.mdx b/apps/website/content/docs/gfm.mdx index 9112b4e1..2462ae76 100644 --- a/apps/website/content/docs/gfm.mdx +++ b/apps/website/content/docs/gfm.mdx @@ -84,6 +84,16 @@ You can disable the table download button: ``` +### Custom Download Filename + +By default, table downloads use `table.csv` and `table.md`. Set a custom base name with `download: { filename }`: + +```tsx + + {markdown} + +``` + ## Task Lists Create interactive todo lists: diff --git a/apps/website/content/docs/interactivity.mdx b/apps/website/content/docs/interactivity.mdx index dc17fe97..4c5b383a 100644 --- a/apps/website/content/docs/interactivity.mdx +++ b/apps/website/content/docs/interactivity.mdx @@ -39,7 +39,15 @@ Tables include a copy button that opens a dropdown menu allowing users to copy t ### Download Tables -Tables can be downloaded in two formats: CSV and Markdown. The download button will be shown for tables in the top-right corner on hover. The download button opens a dropdown menu with options to download as CSV or Markdown, making it easy to export table data for use in spreadsheets or documentation. +Tables can be downloaded in two formats: CSV and Markdown. The download button will be shown for tables in the top-right corner on hover. The download button opens a dropdown menu with options to download as CSV or Markdown, making it easy to export table data for use in spreadsheets or documentation. By default files are named `table.csv` and `table.md`. Customize the base name with `controls.table.download`: + +```tsx + + {markdown} + +``` + +This downloads `report.csv` or `report.md` depending on the format the user chooses. ## Code Block Buttons @@ -49,7 +57,15 @@ Every code block includes a copy button that appears on hover. The copy button w ### Download Code -Code blocks also include a download button that appears on hover. The download button will be shown for code blocks in the top-right corner on hover. The download button will download the code with the appropriate file extension based on language. It will also use "file.[extension]" as the filename. It will preserve formatting and indentation. +Code blocks also include a download button that appears on hover. The download button will be shown for code blocks in the top-right corner on hover. The download button will download the code with the appropriate file extension based on language. It will also use "file.[extension]" as the filename by default. Customize the base name with `controls.code.download`: + +```tsx + + {markdown} + +``` + +A JavaScript block would download as `myScript.js`. It will preserve formatting and indentation. ## Mermaid Diagram Buttons @@ -59,7 +75,13 @@ Mermaid diagrams include a copy button that allows users to copy the diagram sou ### Download Diagrams -Mermaid diagrams can be downloaded as SVG files. The download button will be shown for Mermaid diagrams in the top-right corner on hover. The download button will download the rendered diagram as an SVG file. It will use "diagram.svg" as the default filename. +Mermaid diagrams can be downloaded as SVG, PNG, or Mermaid source (`.mmd`). The download button will be shown for Mermaid diagrams in the top-right corner on hover. By default files are named `diagram.svg`, `diagram.png`, and `diagram.mmd`. Customize the base name with `controls.mermaid.download`: + +```tsx + + {markdown} + +``` ### Pan and Zoom diff --git a/apps/website/content/docs/plugins/mermaid.mdx b/apps/website/content/docs/plugins/mermaid.mdx index e0d89f30..53bc13f7 100644 --- a/apps/website/content/docs/plugins/mermaid.mdx +++ b/apps/website/content/docs/plugins/mermaid.mdx @@ -469,7 +469,7 @@ Click the fullscreen button to view the diagram in an overlay with a dark backgr ### Download -Download the diagram as an SVG file for use in presentations or documentation. +Download the diagram as SVG, PNG, or Mermaid source. By default files are named `diagram.svg`, `diagram.png`, and `diagram.mmd`. Pass `download: { filename: "flowchart" }` to use a custom base name. ### Copy @@ -485,7 +485,7 @@ You can customize which controls are shown: controls={{ mermaid: { fullscreen: true, - download: true, + download: { filename: "flowchart" }, // Download as flowchart.svg / flowchart.png / flowchart.mmd copy: true, panZoom: true, // Enable pan and zoom controls }, From f7f8d78e5e31cf470394e496192bbcb1acd07c79 Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Fri, 21 Aug 2026 19:24:36 +0530 Subject: [PATCH 10/12] docs: update Streamdown documentation for enhanced download controls - Clarified the `controls` configuration in the Streamdown component to include custom download filenames for tables, code blocks, and mermaid diagrams. - Updated examples to demonstrate the use of `download: { filename }` for setting specific filenames during downloads. - Enhanced descriptions in the API and features documentation to reflect the new capabilities and usage scenarios. --- skills/streamdown/SKILL.md | 2 +- skills/streamdown/references/api.md | 17 ++++++++++++++--- skills/streamdown/references/features.md | 18 ++++++++++++------ skills/streamdown/references/plugins.md | 9 +++++++-- 4 files changed, 34 insertions(+), 12 deletions(-) diff --git a/skills/streamdown/SKILL.md b/skills/streamdown/SKILL.md index 1875ccc5..70a2df54 100644 --- a/skills/streamdown/SKILL.md +++ b/skills/streamdown/SKILL.md @@ -110,7 +110,7 @@ export default function Chat() { | `isAnimating` | `boolean` | `false` | Streaming indicator | | `caret` | `"block" \| "circle"` | — | Cursor style | | `components` | `Components` | — | Custom element overrides | -| `controls` | `boolean \| object` | `true` | Interactive buttons | +| `controls` | `boolean \| object` | `true` | Interactive buttons; `download: { filename }` sets custom download names | | `linkSafety` | `LinkSafetyConfig` | `{ enabled: true }` | Link confirmation modal | | `shikiTheme` | `[light, dark]` | `['github-light', 'github-dark']` | Code themes | | `className` | `string` | — | Container class | diff --git a/skills/streamdown/references/api.md b/skills/streamdown/references/api.md index bd897036..065b9214 100644 --- a/skills/streamdown/references/api.md +++ b/skills/streamdown/references/api.md @@ -147,11 +147,20 @@ interface RemendOptions { ## ControlsConfig ```tsx +type DownloadControlConfig = boolean | { filename: string }; + type ControlsConfig = boolean | { - table?: boolean; - code?: boolean; + table?: boolean | { + copy?: boolean; + download?: DownloadControlConfig; + fullscreen?: boolean; + }; + code?: boolean | { + copy?: boolean; + download?: DownloadControlConfig; + }; mermaid?: boolean | { - download?: boolean; + download?: DownloadControlConfig; copy?: boolean; fullscreen?: boolean; panZoom?: boolean; @@ -159,6 +168,8 @@ type ControlsConfig = boolean | { }; ``` +Use `download: { filename: "customName" }` to set a custom base filename. The file extension is appended automatically (`file.js`, `table.csv`, `diagram.svg`, etc.). + ## LinkSafetyConfig ```tsx diff --git a/skills/streamdown/references/features.md b/skills/streamdown/references/features.md index 99ee7b47..8a7aba4c 100644 --- a/skills/streamdown/references/features.md +++ b/skills/streamdown/references/features.md @@ -118,10 +118,16 @@ Auto-added buttons for images, tables, code, and Mermaid. ```tsx `) +- **Code blocks:** Copy (raw code), Download (language extension; default `file.`) +- **Mermaid:** Copy (source), Download (SVG/PNG/MMD; default `diagram.`), Fullscreen, Pan/zoom All buttons disabled during streaming when `isAnimating={true}`. diff --git a/skills/streamdown/references/plugins.md b/skills/streamdown/references/plugins.md index 7d542a7e..6d19503f 100644 --- a/skills/streamdown/references/plugins.md +++ b/skills/streamdown/references/plugins.md @@ -126,12 +126,17 @@ const mermaid = createMermaidPlugin({ **Supported diagram types:** Flowcharts, sequence, state, class, pie, Gantt, ER, git graphs. -**Interactive controls:** Fullscreen, download SVG, copy source, pan/zoom. Customize via `controls` prop: +**Interactive controls:** Fullscreen, download SVG/PNG/MMD, copy source, pan/zoom. Customize via `controls` prop: ```tsx ``` From c00be2960dede31206dcc3f9e506a35076ad70c1 Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Fri, 21 Aug 2026 19:24:50 +0530 Subject: [PATCH 11/12] docs: update Streamdown documentation for unified download controls - Enhanced the documentation to reflect the removal of the `codeDownload` prop in favor of a unified `controls` API for customizing download filenames. - Clarified the configuration options for downloads, including the new `download: { filename: "customName" }` format while preserving automatic file-extension mapping. - Updated examples to demonstrate the new capabilities for code, table, and mermaid downloads. --- .changeset/full-donuts-help.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/full-donuts-help.md b/.changeset/full-donuts-help.md index f62cf082..f97be5ad 100644 --- a/.changeset/full-donuts-help.md +++ b/.changeset/full-donuts-help.md @@ -2,7 +2,7 @@ "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 +- Add custom download filenames for code, table, and mermaid via the `controls` prop +- Configure downloads with `download: { filename: "customName" }` while keeping boolean `true`/`false` to show or hide +- Preserve automatic file-extension mapping based on language or export format +- Remove the `codeDownload` prop in favor of the unified `controls` API From ea8e4b61de6a7c8eda0baba41940dbd90905b986 Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Fri, 21 Aug 2026 19:38:41 +0530 Subject: [PATCH 12/12] chore: update .gitignore to include pnpm-store directory - Added .pnpm-store/ to the .gitignore file to prevent pnpm store files from being tracked in the repository. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d102c5ed..a0aec0b4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules .pnp .pnp.js +.pnpm-store/ # Local env files .env