diff --git a/.changeset/disable-autolink-protocols.md b/.changeset/disable-autolink-protocols.md new file mode 100644 index 00000000..1787d9dd --- /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](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 4225a07d..504f7bcf 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 / 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 new file mode 100644 index 00000000..419e0e9b --- /dev/null +++ b/packages/streamdown/__tests__/disable-autolink-protocols.test.tsx @@ -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( + + ); + + 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("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( + + ); + + 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..024e437c 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 / 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 + * + * {"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..a92c260f --- /dev/null +++ b/packages/streamdown/lib/remark/disable-autolink-protocols.ts @@ -0,0 +1,113 @@ +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 an autolink (GFM autolink-literal or + * CommonMark `<...>`) whose protocol is in the disabled set. + * + * 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, + 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; + } + + 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}`; + } + + // Bare `https://...` (text === url) or `www....` (url === `http://` + text). + return node.url === child.value || node.url === `${protocol}//${child.value}`; +} + +/** + * 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 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 + * 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, 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, source)) { + return; + } + parent.children.splice(index, 1, ...node.children); + return index; + }); + }; +};