Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/disable-autolink-protocols.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"streamdown": minor
---

Add a `disableAutolinkProtocols` prop to `<Streamdown>` for disabling GFM autolinking of specific URL protocols (e.g. `mailto`).

```tsx
<Streamdown disableAutolinkProtocols={["mailto"]}>
{"Contact us at hello@example.com"}
</Streamdown>
```

Bare emails and bare URLs whose protocol matches the list (case-insensitive, `"mailto"` and `"mailto:"` are equivalent) are unwrapped back to plain text. Explicit markdown links (`[text](url)`) are left as links, including when the label reconstructs the URL (e.g. `[foo@x.com](mailto:foo@x.com)`). When the prop is omitted, autolinking behavior is completely unchanged.

Closes #607.
5 changes: 5 additions & 0 deletions apps/website/content/docs/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,11 @@ With `dir="auto"`:
type: "Pluggable[]",
default: "Object.values(defaultRemarkPlugins)",
},
disableAutolinkProtocols: {
description:
"Disable GFM / CommonMark autolinking for specific URL protocols (e.g. ['mailto'] stops bare email addresses from becoming links). Accepts protocol names with or without a trailing colon, case-insensitive. Only affects autolinks (bare URLs/emails and <...> forms); explicit [text](url) links are unaffected, even when the label reconstructs the URL.",
type: "string[]",
},
}}
/>

Expand Down
208 changes: 208 additions & 0 deletions packages/streamdown/__tests__/disable-autolink-protocols.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { render } from "@testing-library/react";
import remarkGfm from "remark-gfm";
import { describe, expect, it } from "vitest";
import { defaultRehypePlugins, Streamdown } from "../index";
import { Markdown } from "../lib/markdown";
import { remarkDisableAutolinkProtocols } from "../lib/remark/disable-autolink-protocols";

const rehypePlugins = Object.values(defaultRehypePlugins);

describe("Disable Autolink Protocols (#607)", () => {
describe("remarkPlugins wiring (remarkDisableAutolinkProtocols)", () => {
it("does not link a bare email when mailto is disabled", () => {
const content = "Contact me at foo@example.com for details";
const { container } = render(
<Markdown
children={content}
rehypePlugins={rehypePlugins}
remarkPlugins={[
remarkGfm,
[remarkDisableAutolinkProtocols, ["mailto"]],
]}
/>
);

expect(container.querySelector("a")).toBeNull();
expect(container.textContent).toBe(content);
});

it("keeps default autolink behavior when the plugin is not added", () => {
const content = "Contact me at foo@example.com for details";
const { container } = render(
<Markdown
children={content}
rehypePlugins={rehypePlugins}
remarkPlugins={[remarkGfm]}
/>
);

const link = container.querySelector("a");
expect(link).toBeTruthy();
expect(link?.getAttribute("href")).toBe("mailto:foo@example.com");
expect(link?.textContent).toBe("foo@example.com");
expect(container.textContent).toBe(content);
});

it("keeps explicit markdown mailto links even when mailto is disabled", () => {
const content = "[Email us](mailto:foo@example.com) any time";
const { container } = render(
<Markdown
children={content}
rehypePlugins={rehypePlugins}
remarkPlugins={[
remarkGfm,
[remarkDisableAutolinkProtocols, ["mailto"]],
]}
/>
);

const link = container.querySelector("a");
expect(link).toBeTruthy();
expect(link?.getAttribute("href")).toBe("mailto:foo@example.com");
expect(link?.textContent).toBe("Email us");
});

it("keeps explicit [email](mailto:email) links whose label matches the address", () => {
// mdast-identical to a bare-email GFM autolink — distinguished via source position
const content =
"Write to [foo@example.com](mailto:foo@example.com) please";
const { container } = render(
<Markdown
children={content}
rehypePlugins={rehypePlugins}
remarkPlugins={[
remarkGfm,
[remarkDisableAutolinkProtocols, ["mailto"]],
]}
/>
);

const link = container.querySelector("a");
expect(link).toBeTruthy();
expect(link?.getAttribute("href")).toBe("mailto:foo@example.com");
expect(link?.textContent).toBe("foo@example.com");
expect(container.textContent).toBe("Write to foo@example.com please");
});

it("keeps explicit [url](url) https links when https is disabled", () => {
const content =
"See [https://example.com](https://example.com) for details";
const { container } = render(
<Markdown
children={content}
rehypePlugins={rehypePlugins}
remarkPlugins={[
remarkGfm,
[remarkDisableAutolinkProtocols, ["https"]],
]}
/>
);

const link = container.querySelector("a");
expect(link).toBeTruthy();
expect(link?.getAttribute("href")).toBe("https://example.com/");
expect(link?.textContent).toBe("https://example.com");
expect(container.textContent).toBe("See https://example.com for details");
});

it("still unwraps bare-email and bare-url autolinks after the explicit-link guard", () => {
const content = "Email foo@example.com or visit https://example.com";
const { container } = render(
<Markdown
children={content}
rehypePlugins={rehypePlugins}
remarkPlugins={[
remarkGfm,
[remarkDisableAutolinkProtocols, ["mailto", "https"]],
]}
/>
);

expect(container.querySelector("a")).toBeNull();
expect(container.textContent).toBe(content);
});

it("still links http/https autolinks when only mailto is disabled", () => {
const content = "Visit https://example.com for more";
const { container } = render(
<Markdown
children={content}
rehypePlugins={rehypePlugins}
remarkPlugins={[
remarkGfm,
[remarkDisableAutolinkProtocols, ["mailto"]],
]}
/>
);

const link = container.querySelector("a");
expect(link).toBeTruthy();
expect(link?.getAttribute("href")).toBe("https://example.com/");
});

it("is case-insensitive and accepts a trailing colon", () => {
for (const protocol of ["mailto", "MAILTO", "mailto:", "MailTo:"]) {
const content = "foo@example.com";
const { container } = render(
<Markdown
children={content}
rehypePlugins={rehypePlugins}
remarkPlugins={[
remarkGfm,
[remarkDisableAutolinkProtocols, [protocol]],
]}
/>
);

expect(container.querySelector("a")).toBeNull();
}
});

it("disables http(s) autolinks when https is disabled, leaving mailto untouched", () => {
const content = "See https://example.com or email foo@example.com";
const { container } = render(
<Markdown
children={content}
rehypePlugins={rehypePlugins}
remarkPlugins={[
remarkGfm,
[remarkDisableAutolinkProtocols, ["https"]],
]}
/>
);

const links = container.querySelectorAll("a");
expect(links.length).toBe(1);
expect(links[0]?.getAttribute("href")).toBe("mailto:foo@example.com");
expect(container.textContent).toBe(content);
});
});

describe("Streamdown disableAutolinkProtocols prop", () => {
it("unwraps disabled bare-email autolinks to plain text", () => {
const content = "Contact foo@example.com now";
const { container } = render(
<Streamdown
disableAutolinkProtocols={["mailto"]}
linkSafety={{ enabled: false }}
>
{content}
</Streamdown>
);

expect(container.querySelector('[data-streamdown="link"]')).toBeNull();
expect(container.textContent).toBe(content);
});

it("leaves autolinks unchanged when the prop is not provided", () => {
const content = "Contact foo@example.com now";
const { container } = render(
<Streamdown linkSafety={{ enabled: false }}>{content}</Streamdown>
);

const link = container.querySelector('[data-streamdown="link"]');
expect(link).toBeTruthy();
expect(link?.getAttribute("href")).toBe("mailto:foo@example.com");
});
});
});
31 changes: 30 additions & 1 deletion packages/streamdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { preprocessLiteralTagContent } from "./lib/preprocess-literal-tag-conten
import { rehypeBlockDirection } from "./lib/rehype/block-direction";
import { rehypeLiteralTagContent } from "./lib/rehype/literal-tag-content";
import { remarkCodeMeta } from "./lib/remark/code-meta";
import { remarkDisableAutolinkProtocols } from "./lib/remark/disable-autolink-protocols";
import type { CSVSeparator } from "./lib/table/utils";
import {
defaultTranslations,
Expand Down Expand Up @@ -254,6 +255,22 @@ export type StreamdownProps = Options & {
* ```
*/
literalTagContent?: string[];
/**
* Disable GFM / CommonMark autolinking for specific URL protocols (e.g. bare
* email addresses become `mailto:` autolinks). Accepts protocol names with
* or without a trailing colon, case-insensitive (`"mailto"` and `"mailto:"`
* are equivalent). Only affects autolinks (bare URLs/emails and `<...>`
* forms) — explicit markdown links (`[text](url)`), including cases where
* the label reconstructs the URL, are left as links.
*
* @example
* ```tsx
* <Streamdown disableAutolinkProtocols={["mailto"]}>
* {"Contact us at hello@example.com"}
* </Streamdown>
* ```
*/
disableAutolinkProtocols?: string[];
/** Override UI strings for i18n / custom labels */
translations?: Partial<StreamdownTranslations>;
/** Custom icons to override the default icons used in controls */
Expand Down Expand Up @@ -511,6 +528,7 @@ export const Streamdown = memo(
lineNumbers = true,
allowedTags,
literalTagContent,
disableAutolinkProtocols,
translations,
icons: iconOverrides,
prefix,
Expand Down Expand Up @@ -764,6 +782,16 @@ export const Streamdown = memo(
}
// Default plugins (includes remarkGfm)
result = [...result, ...remarkPlugins];
// Optionally strip GFM autolink-literal links for disabled protocols
// (e.g. mailto). Runs right after remarkGfm since it inspects the
// link nodes remarkGfm's autolink-literal extension creates. Skipped
// entirely when unset so default behavior/pipeline is unchanged.
if (disableAutolinkProtocols && disableAutolinkProtocols.length > 0) {
result = [
...result,
[remarkDisableAutolinkProtocols, disableAutolinkProtocols],
];
}
// CJK plugins that must run AFTER remarkGfm (e.g., autolink boundary)
if (plugins?.cjk) {
result = [...result, ...plugins.cjk.remarkPluginsAfter];
Expand All @@ -773,7 +801,7 @@ export const Streamdown = memo(
result = [...result, plugins.math.remarkPlugin];
}
return result;
}, [remarkPlugins, plugins?.math, plugins?.cjk]);
}, [remarkPlugins, plugins?.math, plugins?.cjk, disableAutolinkProtocols]);

const mergedRehypePlugins = useMemo(() => {
let result = rehypePlugins;
Expand Down Expand Up @@ -997,6 +1025,7 @@ export const Streamdown = memo(
prevProps.tableMaxHeight === nextProps.tableMaxHeight &&
prevProps.normalizeHtmlIndentation === nextProps.normalizeHtmlIndentation &&
prevProps.literalTagContent === nextProps.literalTagContent &&
prevProps.disableAutolinkProtocols === nextProps.disableAutolinkProtocols &&
JSON.stringify(prevProps.translations) ===
JSON.stringify(nextProps.translations) &&
prevProps.prefix === nextProps.prefix &&
Expand Down
Loading
Loading