diff --git a/.changeset/feat-default-component-fallback.md b/.changeset/feat-default-component-fallback.md new file mode 100644 index 00000000..c61f0c28 --- /dev/null +++ b/.changeset/feat-default-component-fallback.md @@ -0,0 +1,26 @@ +--- +"streamdown": minor +--- + +feat: add `defaultComponent` fallback prop for unstyled/custom tag rendering + +Adds a new `defaultComponent` prop to ``. When provided, it is +used as a fallback renderer for any HTML tag or allowed custom tag that does +not have an explicit entry in the `components` map. This enables unstyled / +passthrough rendering without enumerating every tag: + +```tsx + + createElement(node!.tagName, props, children) + } +> + {markdown} + +``` + +`defaultComponent` applies to custom tags declared via `allowedTags` (no +explicit component entry) and to standard HTML tags absent from the built-in +Tailwind component set. Explicit `components` entries always win. + +Refs #543 diff --git a/packages/streamdown/README.md b/packages/streamdown/README.md index 6727e2b2..81f8adcf 100644 --- a/packages/streamdown/README.md +++ b/packages/streamdown/README.md @@ -145,3 +145,49 @@ export default function Chat() { ``` For more info, see the [documentation](https://streamdown.ai/docs). + +## `defaultComponent` — unstyled / custom fallback rendering + +By default, Streamdown renders most HTML elements with Tailwind utility classes. +If you use a different design system (or simply want unstyled output), you can +provide a `defaultComponent` prop. It acts as a **fallback renderer** for any +tag that does not have an explicit entry in the `components` map. + +`defaultComponent` applies to: + +- Custom tags declared via `allowedTags` that have no matching key in `components`. +- Standard HTML tags not covered by the built-in Tailwind component set (e.g. + ``, `
`, `
`). + +Explicit entries in `components` always take precedence over `defaultComponent`. + +```tsx +import { createElement } from "react"; +import { Streamdown } from "streamdown"; + +// Pass-through: renders every unhandled tag as plain HTML + + createElement(node!.tagName, props, children) + } +> + {markdown} + +``` + +You can combine `defaultComponent` with explicit overrides for the tags that +need special treatment: + +```tsx + + createElement(node!.tagName, props, children) + } + components={{ + code: MyCodeBlock, + a: MyLink, + }} +> + {markdown} + +``` diff --git a/packages/streamdown/__tests__/default-component.test.tsx b/packages/streamdown/__tests__/default-component.test.tsx new file mode 100644 index 00000000..2078dcb0 --- /dev/null +++ b/packages/streamdown/__tests__/default-component.test.tsx @@ -0,0 +1,194 @@ +import { render } from "@testing-library/react"; +import { createElement } from "react"; +import { describe, expect, it } from "vitest"; +import { Streamdown } from "../index"; +import type { ExtraProps } from "../lib/markdown"; + +type FallbackProps = Record & ExtraProps; + +/** + * A minimal pass-through renderer: renders the element using its own tag + * name with any props passed down by hast-util-to-jsx-runtime. + */ +const PassThrough = ({ node, children, ...rest }: FallbackProps) => + createElement( + node?.tagName ?? "span", + { ...rest, "data-fallback": "true" }, + children as React.ReactNode + ); + +describe("defaultComponent prop", () => { + describe("allowedTags without explicit component", () => { + it("uses defaultComponent for an allowedTags tag with no component entry", () => { + const { container } = render( + + {"@alice"} + + ); + + // PassThrough renders the original tag; verify data-fallback attribute + const el = container.querySelector("mention"); + expect(el).toBeTruthy(); + expect(el?.getAttribute("data-fallback")).toBe("true"); + expect(el?.textContent).toBe("@alice"); + }); + + it("uses defaultComponent for multiple allowedTags without components", () => { + const { container } = render( + + {"first second"} + + ); + + const tag1 = container.querySelector("tag1"); + const tag2 = container.querySelector("tag2"); + expect(tag1?.getAttribute("data-fallback")).toBe("true"); + expect(tag2?.getAttribute("data-fallback")).toBe("true"); + }); + }); + + describe("explicit components take precedence", () => { + it("explicit component wins over defaultComponent", () => { + const ExplicitTag = ({ children }: FallbackProps) => ( + {children as React.ReactNode} + ); + + const { container } = render( + + {"@bob"} + + ); + + // Explicit component is used, not PassThrough + const explicit = container.querySelector('[data-explicit="true"]'); + expect(explicit).toBeTruthy(); + expect(explicit?.textContent).toBe("@bob"); + + // data-fallback should NOT be present + const fallback = container.querySelector('[data-fallback="true"]'); + expect(fallback).toBeNull(); + }); + + it("explicit p component overrides defaultComponent for paragraph", () => { + const CustomP = ({ children }: React.PropsWithChildren) => ( +

{children}

+ ); + + const { container } = render( + + {"Hello world"} + + ); + + const p = container.querySelector('[data-custom="true"]'); + expect(p).toBeTruthy(); + // defaultComponent must not have been used for

+ const fallback = container.querySelector('[data-fallback="true"]'); + expect(fallback).toBeNull(); + }); + }); + + describe("HTML tags not in defaultComponents", () => { + it("uses defaultComponent for tags absent from the default map (e.g. )", () => { + const { container } = render( + + {"inline span"} + + ); + + const span = container.querySelector('[data-fallback="true"]'); + expect(span).toBeTruthy(); + expect(span?.textContent).toContain("inline span"); + }); + }); + + describe("backward compatibility", () => { + it("behaves identically when defaultComponent is not provided", () => { + const { container: withProp } = render( + {"# Hello"} + ); + const { container: withoutProp } = render( + {"# Hello"} + ); + + // Both should produce equivalent output + expect(withProp.innerHTML).toBe(withoutProp.innerHTML); + // defaultComponents Tailwind classes should still be applied + const h1 = withProp.querySelector("h1"); + expect(h1).toBeTruthy(); + expect(h1?.className).toContain("font-semibold"); + }); + + it("does not add data-fallback when defaultComponent is absent", () => { + const { container } = render( + {"Hello **world**"} + ); + + const fallback = container.querySelector('[data-fallback="true"]'); + expect(fallback).toBeNull(); + }); + }); + + describe("streaming mode", () => { + it("applies defaultComponent in streaming mode for allowedTags", () => { + const { container } = render( + + {"label"} + + ); + + const chip = container.querySelector("chip"); + expect(chip).toBeTruthy(); + expect(chip?.getAttribute("data-fallback")).toBe("true"); + }); + }); + + describe("node prop passthrough", () => { + it("receives node with tagName in defaultComponent", () => { + const tagNames: string[] = []; + const Inspector = ({ node, children }: FallbackProps) => { + if (node?.tagName) { + tagNames.push(node.tagName); + } + return createElement( + node?.tagName ?? "span", + {}, + children as React.ReactNode + ); + }; + + render( + + {"x"} + + ); + + expect(tagNames).toContain("badge"); + }); + }); +}); diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx index be32d9fd..287256e5 100644 --- a/packages/streamdown/index.tsx +++ b/packages/streamdown/index.tsx @@ -3,6 +3,7 @@ import type { MermaidConfig } from "mermaid"; import { type ComponentProps, + type ComponentType, type CSSProperties, createContext, createElement, @@ -30,7 +31,12 @@ import { components as defaultComponents } from "./lib/components"; import { detectTextDirection } from "./lib/detect-direction"; import { type IconMap, IconProvider } from "./lib/icon-context"; import { hasIncompleteCodeFence, hasTable } from "./lib/incomplete-code-utils"; -import { type ExtraProps, Markdown, type Options } from "./lib/markdown"; +import { + type Components, + type ExtraProps, + Markdown, + type Options, +} from "./lib/markdown"; import { parseMarkdownIntoBlocks } from "./lib/parse-blocks"; import { PluginContext } from "./lib/plugin-context"; import type { PluginConfig, ThemeInput } from "./lib/plugin-types"; @@ -104,6 +110,9 @@ export { export type { StreamdownTranslations } from "./lib/translations-context"; export { defaultTranslations } from "./lib/translations-context"; +// Matches lowercase HTML / custom tag names (first char is a-z) +const LOWERCASE_TAG_PATTERN = /^[a-z]/; + // Patterns for HTML indentation normalization // Matches if content starts with an HTML tag (possibly with leading whitespace) const HTML_BLOCK_START_PATTERN = /^[ \t]*<[\w!/?-]/; @@ -205,6 +214,32 @@ export type StreamdownProps = Options & { linkSafety?: LinkSafetyConfig; /** Custom tags to allow through sanitization with their permitted attributes */ allowedTags?: AllowedTags; + /** + * Fallback component rendered for any HTML tag or allowed custom tag that + * does not have an explicit entry in the `components` map. Enables + * unstyled / passthrough rendering without enumerating every tag. + * + * When set, it applies to: + * - Custom tags declared via `allowedTags` that have no matching key in + * `components`. + * - Any standard HTML tag that is not covered by the built-in Tailwind + * component set (e.g. ``, `

`, `
`). + * + * Explicit entries in `components` always take precedence. + * + * @example + * ```tsx + * // Pass-through renderer — renders every unhandled tag as plain HTML + * + * createElement(node!.tagName, props, children) + * } + * > + * {markdown} + * + * ``` + */ + defaultComponent?: React.ComponentType & ExtraProps>; /** * Tags whose children should be treated as plain text (no markdown parsing). * Useful for mention/entity tags in AI UIs where child content is a data @@ -451,6 +486,7 @@ export const Streamdown = memo( linkSafety = defaultLinkSafetyConfig, lineNumbers = true, allowedTags, + defaultComponent, literalTagContent, translations, icons: iconOverrides, @@ -646,13 +682,15 @@ export const Streamdown = memo( const mergedComponents = useMemo(() => { const { inlineCode, ...userComponents } = components ?? {}; - const merged = { + const merged: Record = { ...defaultComponents, ...userComponents, }; if (inlineCode) { - const BlockCode = merged.code; + const BlockCode = merged.code as + | ComponentType & ExtraProps> + | undefined; merged.code = (props: ComponentProps<"code"> & ExtraProps) => { const isInline = !("data-block" in props); if (isInline) { @@ -662,8 +700,56 @@ export const Streamdown = memo( }; } - return merged; - }, [components]); + if (defaultComponent) { + // Eagerly register defaultComponent for allowedTags entries that have + // no explicit component in the user-supplied `components` map. + if (allowedTags) { + for (const tag of Object.keys(allowedTags)) { + if (!Object.hasOwn(merged, tag)) { + merged[tag] = defaultComponent; + } + } + } + + // Wrap in a Proxy so any other tag not explicitly covered (e.g. HTML + // tags absent from defaultComponents like ,
,
) + // also uses defaultComponent instead of rendering as a bare intrinsic + // element. hast-util-to-jsx-runtime resolves components via + // hasOwnProperty (own.call), so we intercept getOwnPropertyDescriptor + // as well as get to satisfy both the presence check and the lookup. + const fallbackDesc: PropertyDescriptor = { + configurable: true, + enumerable: false, + value: defaultComponent, + writable: false, + }; + return new Proxy(merged as Components, { + getOwnPropertyDescriptor(target, prop) { + const ownProp = Object.getOwnPropertyDescriptor(target, prop); + if (ownProp) { + return ownProp; + } + // Intercept lowercase HTML / custom tag names only. + if (typeof prop === "string" && LOWERCASE_TAG_PATTERN.test(prop)) { + return fallbackDesc; + } + return undefined; + }, + get(target, prop, receiver) { + if ( + typeof prop === "string" && + LOWERCASE_TAG_PATTERN.test(prop) && + !Object.hasOwn(target, prop) + ) { + return defaultComponent; + } + return Reflect.get(target, prop, receiver); + }, + }); + } + + return merged as Components; + }, [components, defaultComponent, allowedTags]); // Merge plugin remark plugins (math, cjk) // Order: CJK before -> default (remarkGfm) -> CJK after -> math @@ -865,6 +951,7 @@ export const Streamdown = memo( JSON.stringify(prevProps.translations) === JSON.stringify(nextProps.translations) && prevProps.prefix === nextProps.prefix && - prevProps.dir === nextProps.dir + prevProps.dir === nextProps.dir && + prevProps.defaultComponent === nextProps.defaultComponent ); Streamdown.displayName = "Streamdown";