diff --git a/.changeset/full-donuts-help.md b/.changeset/full-donuts-help.md
new file mode 100644
index 00000000..f97be5ad
--- /dev/null
+++ b/.changeset/full-donuts-help.md
@@ -0,0 +1,8 @@
+---
+"streamdown": minor
+---
+
+- 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
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
diff --git a/apps/website/content/docs/code-blocks.mdx b/apps/website/content/docs/code-blocks.mdx
index cee82ba6..17bcfb61 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 fa45b730..2539a1be 100644
--- a/apps/website/content/docs/configuration.mdx
+++ b/apps/website/content/docs/configuration.mdx
@@ -153,7 +153,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",
},
@@ -337,23 +338,23 @@ 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 b45df23e..291a61ad 100644
--- a/apps/website/content/docs/gfm.mdx
+++ b/apps/website/content/docs/gfm.mdx
@@ -118,6 +118,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}
+
+```
+
### Custom CSV Separator
By default, copied and downloaded CSV uses a comma. Set `controls.table.csvSeparator` to `";"`, `"\t"`, or `"auto"` (picks `;` in comma-decimal locales):
diff --git a/apps/website/content/docs/interactivity.mdx b/apps/website/content/docs/interactivity.mdx
index 88d745ba..2baf5de5 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.
CSV copy and download use a comma by default. Customize the delimiter with `controls.table.csvSeparator` (`","`, `";"`, `"\t"`, or `"auto"`):
@@ -57,7 +65,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
@@ -67,7 +83,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
},
diff --git a/packages/streamdown/__tests__/code-block-download.test.tsx b/packages/streamdown/__tests__/code-block-download.test.tsx
index 74b2e860..c2736ddb 100644
--- a/packages/streamdown/__tests__/code-block-download.test.tsx
+++ b/packages/streamdown/__tests__/code-block-download.test.tsx
@@ -137,4 +137,191 @@ describe("CodeBlockDownloadButton", () => {
);
expect(button?.hasAttribute("disabled")).toBe(true);
});
+
+ it("should use custom filename from controls", 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 filename 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 controls is true", 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 download is enabled without a filename", 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 filename", 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/__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 005f0b7c..8764203b 100644
--- a/packages/streamdown/__tests__/mermaid-download.test.tsx
+++ b/packages/streamdown/__tests__/mermaid-download.test.tsx
@@ -50,11 +50,12 @@ describe("MermaidDownloadDropdown", () => {
const renderWithContext = (
props: any,
- plugin: DiagramPlugin = createMockPlugin()
+ plugin: DiagramPlugin = createMockPlugin(),
+ context = defaultContext
) => {
return render(
-
+
@@ -324,4 +325,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 8c3d285e..00146a34 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("image controls", () => {
diff --git a/packages/streamdown/__tests__/table-dropdowns.test.tsx b/packages/streamdown/__tests__/table-dropdowns.test.tsx
index cddb356a..251c9c4f 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 use csvSeparator from controls for CSV downloads", async () => {
const { save } = await import("../lib/utils");
const onDownload = vi.fn();
@@ -314,6 +344,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 use csvSeparator from controls for CSV button downloads", async () => {
const { save } = await import("../lib/utils");
const onDownload = vi.fn();
diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx
index 9ce7df88..2c6bbe1b 100644
--- a/packages/streamdown/index.tsx
+++ b/packages/streamdown/index.tsx
@@ -137,6 +137,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
| {
@@ -145,19 +147,19 @@ export type ControlsConfig =
| {
copy?: boolean;
csvSeparator?: CSVSeparator;
- 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;
diff --git a/packages/streamdown/lib/code-block/download-button.tsx b/packages/streamdown/lib/code-block/download-button.tsx
index 3edbca76..6c76f481 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 } = 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 = `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 da6b446b..8c57b35e 100644
--- a/packages/streamdown/lib/mermaid/download-button.tsx
+++ b/packages/streamdown/lib/mermaid/download-button.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 { useMermaidPlugin } from "../plugin-context";
import type { MermaidConfig } from "../plugin-types";
@@ -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);
@@ -72,7 +74,7 @@ export const MermaidDownloadDropdown = ({
const serializedSvg = serializeSvgForDownload(svg);
if (format === "svg") {
- const filename = "diagram.svg";
+ const filename = `${baseFilename}.svg`;
const mimeType = "image/svg+xml";
save(filename, serializedSvg, mimeType);
setIsOpen(false);
@@ -82,7 +84,7 @@ export const MermaidDownloadDropdown = ({
if (format === "png") {
const blob = await svgToPngBlob(serializedSvg);
- 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 39e18ccd..ef187969 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";
@@ -69,7 +70,11 @@ export const TableDownloadButton = ({
extension = "csv";
}
- save(`${filename || "table"}.${extension}`, content, mimeType);
+ save(
+ `${filename || getDownloadFilename(controls, "table", "table")}.${extension}`,
+ content,
+ mimeType
+ );
onDownload?.();
} catch (error) {
@@ -136,10 +141,10 @@ export const TableDownloadDropdown = ({
? tableDataToCSV(tableData, csvSeparator)
: 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) {
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 99e2fbe1..b5a68063 100644
--- a/skills/streamdown/references/api.md
+++ b/skills/streamdown/references/api.md
@@ -147,21 +147,22 @@ interface RemendOptions {
## ControlsConfig
```tsx
+type DownloadControlConfig = boolean | { filename: string };
type CSVSeparator = "," | ";" | "\t" | "auto";
type ControlsConfig = boolean | {
table?: boolean | {
copy?: boolean;
- download?: boolean;
+ download?: DownloadControlConfig;
fullscreen?: boolean;
csvSeparator?: CSVSeparator; // default: ","
};
code?: boolean | {
copy?: boolean;
- download?: boolean;
+ download?: DownloadControlConfig;
};
mermaid?: boolean | {
- download?: boolean;
+ download?: DownloadControlConfig;
copy?: boolean;
fullscreen?: boolean;
panZoom?: boolean;
@@ -169,6 +170,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 85f8576a..3e634f07 100644
--- a/skills/streamdown/references/features.md
+++ b/skills/streamdown/references/features.md
@@ -120,12 +120,15 @@ Auto-added buttons for images, tables, code, and Mermaid.
controls={{
table: {
copy: true,
- download: true,
+ download: { filename: "report" }, // report.csv / report.md
csvSeparator: "auto", // "," | ";" | "\t" | "auto"
},
- code: false, // No copy/download on code blocks
+ code: {
+ copy: true,
+ download: { filename: "myScript" }, // myScript.js, myScript.py, etc.
+ },
mermaid: {
- download: true,
+ download: { filename: "flowchart" }, // flowchart.svg / flowchart.png / flowchart.mmd
copy: true,
fullscreen: true,
panZoom: false,
@@ -136,9 +139,9 @@ Auto-added buttons for images, tables, code, and Mermaid.
**Button types by element:**
- **Images:** Download (auto-detected format, alt text as filename)
-- **Tables:** Copy (CSV/TSV/HTML), Download (CSV/Markdown)
-- **Code blocks:** Copy (raw code), Download (with correct extension)
-- **Mermaid:** Copy (source), Download (SVG), Fullscreen, Pan/zoom
+- **Tables:** Copy (CSV/TSV/HTML), Download (CSV/Markdown; default `table.`)
+- **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
```