Skip to content
Merged
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 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
node_modules
.pnp
.pnp.js
.pnpm-store/

# Local env files
.env
Expand Down
16 changes: 16 additions & 0 deletions apps/website/content/docs/code-blocks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,22 @@ Disable individual code block buttons using the `controls` prop:
<Streamdown controls={false}>{markdown}</Streamdown>
```

### 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.<ext>`.

```tsx title="app/page.tsx"
<Streamdown
controls={{
code: {
download: { filename: "myScript" },
},
}}
>
{markdown}
</Streamdown>
```

## Inline Code

Inline code uses backticks and receives subtle styling:
Expand Down
13 changes: 8 additions & 5 deletions apps/website/content/docs/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
Expand Down Expand Up @@ -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"
<Streamdown
controls={{
table: {
copy: true, // Show table copy button
download: true, // Show table download button
download: { filename: "report" }, // Download as report.csv / report.md
fullscreen: true, // Show table fullscreen button
csvSeparator: ",", // "," | ";" | "\t" | "auto"
},
code: {
copy: true, // Show code copy button
download: true, // Show code download button
download: { filename: "myScript" }, // Download as myScript.js, myScript.py, etc.
},
mermaid: {
download: true, // Show mermaid download button
download: { filename: "flowchart" }, // Download as flowchart.svg / flowchart.png / flowchart.mmd
copy: true, // Show mermaid copy button
fullscreen: true, // Show mermaid fullscreen button
panZoom: true, // Show mermaid pan/zoom controls
Expand All @@ -364,6 +365,8 @@ The `controls` prop can be configured granularly:
</Streamdown>
```

You can still use `download: true` (or omit it) to keep the default filenames: `file.<ext>` 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:
Expand Down
10 changes: 10 additions & 0 deletions apps/website/content/docs/gfm.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,16 @@ You can disable the table download button:
</Streamdown>
```

### Custom Download Filename

By default, table downloads use `table.csv` and `table.md`. Set a custom base name with `download: { filename }`:

```tsx
<Streamdown controls={{ table: { download: { filename: "report" } } }}>
{markdown}
</Streamdown>
```

### 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):
Expand Down
28 changes: 25 additions & 3 deletions apps/website/content/docs/interactivity.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
<Streamdown controls={{ table: { download: { filename: "report" } } }}>
{markdown}
</Streamdown>
```

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"`):

Expand All @@ -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
<Streamdown controls={{ code: { download: { filename: "myScript" } } }}>
{markdown}
</Streamdown>
```

A JavaScript block would download as `myScript.js`. It will preserve formatting and indentation.

## Mermaid Diagram Buttons

Expand All @@ -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
<Streamdown controls={{ mermaid: { download: { filename: "flowchart" } } }}>
{markdown}
</Streamdown>
```

### Pan and Zoom

Expand Down
4 changes: 2 additions & 2 deletions apps/website/content/docs/plugins/mermaid.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
},
Expand Down
187 changes: 187 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,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(
<StreamdownContext.Provider
value={{
shikiTheme: ["github-light", "github-dark"],
controls: {
code: { download: { filename: "myScript" } },
},
isAnimating: false,
mode: "streaming",
}}
>
<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 filename with unknown language", async () => {
const { save } = await import("../lib/utils");

const { container } = render(
<StreamdownContext.Provider
value={{
shikiTheme: ["github-light", "github-dark"],
controls: {
code: { download: { filename: "output" } },
},
isAnimating: false,
mode: "streaming",
}}
>
<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 controls is true", async () => {
const { save } = await import("../lib/utils");

const { container } = render(
<StreamdownContext.Provider
value={{
shikiTheme: ["github-light", "github-dark"],
controls: true,
isAnimating: false,
mode: "streaming",
}}
>
<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 download is enabled without a filename", async () => {
const { save } = await import("../lib/utils");

const { container } = render(
<StreamdownContext.Provider
value={{
shikiTheme: ["github-light", "github-dark"],
controls: { code: { download: true } },
isAnimating: false,
mode: "streaming",
}}
>
<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 filename", async () => {
const { save } = await import("../lib/utils");

const { container } = render(
<StreamdownContext.Provider
value={{
shikiTheme: ["github-light", "github-dark"],
controls: {
code: { download: { filename: "my-config.backup" } },
},
isAnimating: false,
mode: "streaming",
}}
>
<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"
);
});
});
Loading
Loading