From f76cfe5fa966e2b3275829d5d99a634b13f422cc Mon Sep 17 00:00:00 2001 From: Dmitrii Troitskii Date: Sat, 5 Sep 2026 06:14:00 +0000 Subject: [PATCH 1/3] feat: support disableAutolinkProtocols to opt out of GFM autolinks per protocol Adds an optional disableAutolinkProtocols prop to that lets consumers turn off GFM autolink-literal linking for specific URL protocols (e.g. mailto). Implemented as a remark plugin that runs after remark-gfm, identifying autolink-literal link nodes structurally (single text child reconstructing the URL) and unwrapping them to plain text when their protocol is disabled. Explicit markdown links ([text](url)) are left alone. Protocol names are case-insensitive and accept an optional trailing colon. The pipeline is unchanged when the prop is omitted. Closes #607 --- .changeset/disable-autolink-protocols.md | 15 ++ apps/website/content/docs/configuration.mdx | 5 + .../disable-autolink-protocols.test.tsx | 148 ++++++++++++++++++ packages/streamdown/index.tsx | 31 +++- .../lib/remark/disable-autolink-protocols.ts | 103 ++++++++++++ 5 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 .changeset/disable-autolink-protocols.md create mode 100644 packages/streamdown/__tests__/disable-autolink-protocols.test.tsx create mode 100644 packages/streamdown/lib/remark/disable-autolink-protocols.ts diff --git a/.changeset/disable-autolink-protocols.md b/.changeset/disable-autolink-protocols.md new file mode 100644 index 00000000..c723eaa0 --- /dev/null +++ b/.changeset/disable-autolink-protocols.md @@ -0,0 +1,15 @@ +--- +"streamdown": minor +--- + +Add a `disableAutolinkProtocols` prop to `` for disabling GFM autolinking of specific URL protocols (e.g. `mailto`). + +```tsx + + {"Contact us at hello@example.com"} + +``` + +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](mailto:...)`) are left as links. When the prop is omitted, autolinking behavior is completely unchanged. + +Closes #607. diff --git a/apps/website/content/docs/configuration.mdx b/apps/website/content/docs/configuration.mdx index 4225a07d..74fee5f7 100644 --- a/apps/website/content/docs/configuration.mdx +++ b/apps/website/content/docs/configuration.mdx @@ -129,6 +129,11 @@ With `dir="auto"`: type: "Pluggable[]", default: "Object.values(defaultRemarkPlugins)", }, + disableAutolinkProtocols: { + description: + "Disable GFM 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 literal autolinks created by remark-gfm; explicit [text](url) links are unaffected.", + type: "string[]", + }, }} /> diff --git a/packages/streamdown/__tests__/disable-autolink-protocols.test.tsx b/packages/streamdown/__tests__/disable-autolink-protocols.test.tsx new file mode 100644 index 00000000..eb067148 --- /dev/null +++ b/packages/streamdown/__tests__/disable-autolink-protocols.test.tsx @@ -0,0 +1,148 @@ +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( + + ); + + 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( + + ); + + 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( + + ); + + const link = container.querySelector("a"); + expect(link).toBeTruthy(); + expect(link?.getAttribute("href")).toBe("mailto:foo@example.com"); + expect(link?.textContent).toBe("Email us"); + }); + + it("still links http/https autolinks when only mailto is disabled", () => { + const content = "Visit https://example.com for more"; + const { container } = render( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + {content} + + ); + + 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( + {content} + ); + + const link = container.querySelector('[data-streamdown="link"]'); + expect(link).toBeTruthy(); + expect(link?.getAttribute("href")).toBe("mailto:foo@example.com"); + }); + }); +}); diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx index fa93a3ff..976622ca 100644 --- a/packages/streamdown/index.tsx +++ b/packages/streamdown/index.tsx @@ -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, @@ -254,6 +255,22 @@ export type StreamdownProps = Options & { * ``` */ literalTagContent?: string[]; + /** + * Disable GFM 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 literal autolinks created by `remark-gfm` + * (bare URLs/emails) — explicit markdown links (`[text](url)`) are left + * as links. + * + * @example + * ```tsx + * + * {"Contact us at hello@example.com"} + * + * ``` + */ + disableAutolinkProtocols?: string[]; /** Override UI strings for i18n / custom labels */ translations?: Partial; /** Custom icons to override the default icons used in controls */ @@ -511,6 +528,7 @@ export const Streamdown = memo( lineNumbers = true, allowedTags, literalTagContent, + disableAutolinkProtocols, translations, icons: iconOverrides, prefix, @@ -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]; @@ -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; @@ -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 && diff --git a/packages/streamdown/lib/remark/disable-autolink-protocols.ts b/packages/streamdown/lib/remark/disable-autolink-protocols.ts new file mode 100644 index 00000000..28555ee3 --- /dev/null +++ b/packages/streamdown/lib/remark/disable-autolink-protocols.ts @@ -0,0 +1,103 @@ +import type { Link, Root } from "mdast"; +import type { Plugin } from "unified"; +import { visit } from "unist-util-visit"; + +// Matches the URI scheme at the start of a link's `url` (e.g. "mailto:", "http:"). +const PROTOCOL_PATTERN = /^([a-zA-Z][a-zA-Z\d+\-.]*:)/; + +/** + * Normalizes a list of user-supplied protocol names into a lowercase set of + * `"scheme:"` strings. Accepts protocols with or without a trailing colon + * (e.g. `"mailto"` and `"mailto:"` are equivalent) and is case-insensitive. + */ +export const normalizeAutolinkProtocols = (protocols: string[]): Set => + new Set( + protocols + .map((protocol) => protocol.trim().toLowerCase()) + .filter((protocol) => protocol.length > 0) + .map((protocol) => (protocol.endsWith(":") ? protocol : `${protocol}:`)) + ); + +/** + * Determines whether a `link` node is a GFM autolink-literal (created by + * `remark-gfm`'s autolink-literal extension for bare URLs/emails) whose + * protocol is in the disabled set. + * + * `mdast-util-gfm-autolink-literal` does not tag the nodes it creates, so + * autolinks are identified structurally: they have exactly one `text` child + * whose visible value reconstructs the node's `url`. Bare emails become + * `{ url: "mailto:", children: [{ type: "text", value: "" }] }`; + * bare http(s)/www URLs become a link whose single text child equals the URL + * (optionally without the `http://` prefix that GFM adds for `www.` links). + * + * Explicit markdown links (`[text](url)`) are intentionally left untouched + * unless their visible text happens to exactly reconstruct the URL, in which + * case they are indistinguishable from an autolink at the mdast level. + */ +function isDisabledAutolink( + node: Link, + disabledProtocols: Set +): boolean { + if (node.children.length !== 1) { + return false; + } + + const [child] = node.children; + if (child.type !== "text") { + return false; + } + + const protocolMatch = PROTOCOL_PATTERN.exec(node.url); + if (!protocolMatch) { + return false; + } + + const protocol = protocolMatch[1].toLowerCase(); + if (!disabledProtocols.has(protocol)) { + return false; + } + + if (protocol === "mailto:") { + return node.url === `mailto:${child.value}`; + } + + return node.url === child.value || node.url === `${protocol}//${child.value}`; +} + +/** + * Remark plugin that removes GFM autolink-literal links whose protocol + * matches one of the configured `protocols`, unwrapping them back to plain + * text. Must run AFTER `remark-gfm` in the plugin pipeline so the autolink + * nodes exist for it to inspect. + * + * Uses the standard unified `[plugin, options]` tuple form (rather than a + * plugin factory) so Streamdown's internal processor cache — which keys + * processors by plugin name plus `JSON.stringify(options)` — can tell + * different `protocols` configurations apart. A factory returning a fresh + * closure per call would always serialize to the same anonymous-function + * key and silently reuse a stale cached processor. + * + * A no-op (no protocols configured) skips the tree traversal entirely. + */ +export const remarkDisableAutolinkProtocols: Plugin<[string[]?], Root> = ( + protocols = [] +) => { + const disabledProtocols = normalizeAutolinkProtocols(protocols); + + return (tree: Root) => { + if (disabledProtocols.size === 0) { + return; + } + + visit(tree, "link", (node, index, parent) => { + if (!parent || index === undefined) { + return; + } + if (!isDisabledAutolink(node, disabledProtocols)) { + return; + } + parent.children.splice(index, 1, ...node.children); + return index; + }); + }; +}; From b88ec57ed9167bf7a410ff7165c405b80752ee91 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Tue, 15 Sep 2026 13:44:05 +0200 Subject: [PATCH 2/3] fix: imprrove normal link detection --- .changeset/disable-autolink-protocols.md | 2 +- apps/website/content/docs/configuration.mdx | 2 +- .../disable-autolink-protocols.test.tsx | 63 +++++++++++++++++++ packages/streamdown/index.tsx | 12 ++-- .../lib/remark/disable-autolink-protocols.ts | 46 ++++++++------ 5 files changed, 99 insertions(+), 26 deletions(-) diff --git a/.changeset/disable-autolink-protocols.md b/.changeset/disable-autolink-protocols.md index c723eaa0..1787d9dd 100644 --- a/.changeset/disable-autolink-protocols.md +++ b/.changeset/disable-autolink-protocols.md @@ -10,6 +10,6 @@ Add a `disableAutolinkProtocols` prop to `` for disabling GFM autoli ``` -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](mailto:...)`) are left as links. When the prop is omitted, autolinking behavior is completely unchanged. +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. diff --git a/apps/website/content/docs/configuration.mdx b/apps/website/content/docs/configuration.mdx index 74fee5f7..504f7bcf 100644 --- a/apps/website/content/docs/configuration.mdx +++ b/apps/website/content/docs/configuration.mdx @@ -131,7 +131,7 @@ With `dir="auto"`: }, disableAutolinkProtocols: { description: - "Disable GFM 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 literal autolinks created by remark-gfm; explicit [text](url) links are unaffected.", + "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[]", }, }} diff --git a/packages/streamdown/__tests__/disable-autolink-protocols.test.tsx b/packages/streamdown/__tests__/disable-autolink-protocols.test.tsx index eb067148..a9b9c115 100644 --- a/packages/streamdown/__tests__/disable-autolink-protocols.test.tsx +++ b/packages/streamdown/__tests__/disable-autolink-protocols.test.tsx @@ -62,6 +62,69 @@ describe("Disable Autolink Protocols (#607)", () => { 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx index 976622ca..024e437c 100644 --- a/packages/streamdown/index.tsx +++ b/packages/streamdown/index.tsx @@ -256,12 +256,12 @@ export type StreamdownProps = Options & { */ literalTagContent?: string[]; /** - * Disable GFM 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 literal autolinks created by `remark-gfm` - * (bare URLs/emails) — explicit markdown links (`[text](url)`) are left - * as links. + * 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 diff --git a/packages/streamdown/lib/remark/disable-autolink-protocols.ts b/packages/streamdown/lib/remark/disable-autolink-protocols.ts index 28555ee3..a92c260f 100644 --- a/packages/streamdown/lib/remark/disable-autolink-protocols.ts +++ b/packages/streamdown/lib/remark/disable-autolink-protocols.ts @@ -19,25 +19,28 @@ export const normalizeAutolinkProtocols = (protocols: string[]): Set => ); /** - * Determines whether a `link` node is a GFM autolink-literal (created by - * `remark-gfm`'s autolink-literal extension for bare URLs/emails) whose - * protocol is in the disabled set. + * Determines whether a `link` node is an autolink (GFM autolink-literal or + * CommonMark `<...>`) whose protocol is in the disabled set. * - * `mdast-util-gfm-autolink-literal` does not tag the nodes it creates, so - * autolinks are identified structurally: they have exactly one `text` child - * whose visible value reconstructs the node's `url`. Bare emails become - * `{ url: "mailto:", children: [{ type: "text", value: "" }] }`; - * bare http(s)/www URLs become a link whose single text child equals the URL - * (optionally without the `http://` prefix that GFM adds for `www.` links). - * - * Explicit markdown links (`[text](url)`) are intentionally left untouched - * unless their visible text happens to exactly reconstruct the URL, in which - * case they are indistinguishable from an autolink at the mdast level. + * GFM / CommonMark autolinks and explicit markdown links can be mdast- + * identical when the label reconstructs the URL (e.g. bare `foo@x.com` vs + * `[foo@x.com](mailto:foo@x.com)`). When position info is present we reject + * any node whose source opens with `[` — that is always an intentional + * resource link. Autolinks are then identified structurally: a single `text` + * child whose value reconstructs `url` (accounting for the `mailto:` / + * `http://` prefixes GFM adds). */ function isDisabledAutolink( node: Link, - disabledProtocols: Set + disabledProtocols: Set, + source: string ): boolean { + // Explicit `[label](url)` resource links always open with `[` in source. + const start = node.position?.start?.offset; + if (typeof start === "number" && source.charCodeAt(start) === 91 /* [ */) { + return false; + } + if (node.children.length !== 1) { return false; } @@ -61,15 +64,20 @@ function isDisabledAutolink( return node.url === `mailto:${child.value}`; } + // Bare `https://...` (text === url) or `www....` (url === `http://` + text). return node.url === child.value || node.url === `${protocol}//${child.value}`; } /** - * Remark plugin that removes GFM autolink-literal links whose protocol + * Remark plugin that removes GFM / CommonMark autolinks whose protocol * matches one of the configured `protocols`, unwrapping them back to plain - * text. Must run AFTER `remark-gfm` in the plugin pipeline so the autolink + * text. Must run AFTER `remark-gfm` in the plugin pipeline so GFM autolink * nodes exist for it to inspect. * + * Explicit markdown links (`[text](url)`) are left alone, including cases + * where the label text reconstructs the URL — those are distinguished via + * source positions (`[` opener) rather than mdast shape alone. + * * Uses the standard unified `[plugin, options]` tuple form (rather than a * plugin factory) so Streamdown's internal processor cache — which keys * processors by plugin name plus `JSON.stringify(options)` — can tell @@ -84,16 +92,18 @@ export const remarkDisableAutolinkProtocols: Plugin<[string[]?], Root> = ( ) => { const disabledProtocols = normalizeAutolinkProtocols(protocols); - return (tree: Root) => { + return (tree: Root, file: { value?: unknown }) => { if (disabledProtocols.size === 0) { return; } + const source = String(file.value ?? ""); + visit(tree, "link", (node, index, parent) => { if (!parent || index === undefined) { return; } - if (!isDisabledAutolink(node, disabledProtocols)) { + if (!isDisabledAutolink(node, disabledProtocols, source)) { return; } parent.children.splice(index, 1, ...node.children); From eb3310c156c3c76c2907df31f91154c5ac805b0c Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Tue, 15 Sep 2026 13:46:13 +0200 Subject: [PATCH 3/3] Update disable-autolink-protocols.test.tsx --- .../__tests__/disable-autolink-protocols.test.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/streamdown/__tests__/disable-autolink-protocols.test.tsx b/packages/streamdown/__tests__/disable-autolink-protocols.test.tsx index a9b9c115..419e0e9b 100644 --- a/packages/streamdown/__tests__/disable-autolink-protocols.test.tsx +++ b/packages/streamdown/__tests__/disable-autolink-protocols.test.tsx @@ -102,14 +102,11 @@ describe("Disable Autolink Protocols (#607)", () => { 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" - ); + 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 content = "Email foo@example.com or visit https://example.com"; const { container } = render(