diff --git a/.changeset/feat-default-component-fallback.md b/.changeset/feat-default-component-fallback.md new file mode 100644 index 00000000..6ccc27c6 --- /dev/null +++ b/.changeset/feat-default-component-fallback.md @@ -0,0 +1,27 @@ +--- +"streamdown": minor +--- + +feat: add `fallbackComponent` prop for missing map entries / `allowedTags` + +Adds a new `fallbackComponent` 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: + +```tsx + + createElement(node!.tagName, props, children) + } +> + {markdown} + +``` + +`fallbackComponent` applies to custom tags declared via `allowedTags` (with no +explicit component entry) and to standard HTML tags absent from the built-in +component set (e.g. ``, ``, `
`, `
`). Built-in and explicit +`components` entries always win — this is not a full unstyled mode. + +Refs #543 diff --git a/apps/website/content/docs/components.mdx b/apps/website/content/docs/components.mdx index 50ffee35..6fdf7bf3 100644 --- a/apps/website/content/docs/components.mdx +++ b/apps/website/content/docs/components.mdx @@ -314,6 +314,43 @@ You can allow multiple custom tags: ``` +### Fallback for missing map entries + +When you only need a shared renderer for tags that are **not** already in the +built-in map (or your `components` overrides), use `fallbackComponent` instead of +enumerating every tag: + +```tsx title="app/page.tsx" +import { createElement } from "react"; + + + createElement(node!.tagName, props, children) + } + components={{ + // Still wins over fallbackComponent for this tag + mention: ({ user_id, children }) => ( + {children} + ), + }} +> + {markdown} + +``` + +`fallbackComponent` also applies to standard HTML tags absent from both the +built-in set and `components` (for example ``, ``, `
`, `
`). + + + This is **not** a full unstyled mode. Built-in renderers (`h1`, `p`, `code`, + …) and any explicit `components` entries always take precedence. To restyle + those tags, override them in `components`. + + ### Data Attributes Use `data*` in the attributes array to allow all `data-*` attributes on a tag: diff --git a/apps/website/content/docs/configuration.mdx b/apps/website/content/docs/configuration.mdx index d87f0b25..b7921205 100644 --- a/apps/website/content/docs/configuration.mdx +++ b/apps/website/content/docs/configuration.mdx @@ -74,9 +74,14 @@ Streamdown can be configured to suit your needs. This guide will walk you throug description: "Custom component overrides for Markdown elements", type: "object", }, + fallbackComponent: { + description: + "Fallback renderer for HTML tags or allowedTags entries that have no matching key in components. Built-in and explicit components entries always win — this is not a full unstyled mode. See Components.", + type: "React.ComponentType & ExtraProps>", + }, allowedTags: { description: - "Custom HTML tags to allow through sanitization, with their permitted attributes. Use with 'components' to render custom tags like or . Only works with default rehype plugins.", + "Custom HTML tags to allow through sanitization, with their permitted attributes. Use with 'components' or 'fallbackComponent' to render custom tags like or . Only works with default rehype plugins.", type: "Record", }, literalTagContent: { diff --git a/packages/streamdown/README.md b/packages/streamdown/README.md index 6727e2b2..02e61afc 100644 --- a/packages/streamdown/README.md +++ b/packages/streamdown/README.md @@ -145,3 +145,46 @@ export default function Chat() { ``` For more info, see the [documentation](https://streamdown.ai/docs). + +## `fallbackComponent` — fallback for missing map entries + +Streamdown ships built-in renderers for common markdown tags. For tags that are +**not** in that map — and not overridden via `components` — you can provide a +`fallbackComponent`. Useful for `allowedTags` custom elements and uncovered HTML +tags like ``, ``, `
`, or `
`. + +This is **not** a full unstyled mode: built-in entries (and any explicit +`components` overrides) still take precedence. To restyle tags that already have +defaults (e.g. `h1`, `p`, `code`), pass them in `components`. + +```tsx +import { createElement } from "react"; +import { Streamdown } from "streamdown"; + +// Render missing map entries / allowedTags via a pass-through + + createElement(node!.tagName, props, children) + } +> + {markdown} + +``` + +Combine with explicit overrides when some tags need special treatment: + +```tsx + + createElement(node!.tagName, props, children) + } + components={{ + code: MyCodeBlock, + a: MyLink, + }} +> + {markdown} + +``` diff --git a/packages/streamdown/__tests__/fallback-component.test.tsx b/packages/streamdown/__tests__/fallback-component.test.tsx new file mode 100644 index 00000000..794254e0 --- /dev/null +++ b/packages/streamdown/__tests__/fallback-component.test.tsx @@ -0,0 +1,205 @@ +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("fallbackComponent prop", () => { + describe("allowedTags without explicit component", () => { + it("uses fallbackComponent 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 fallbackComponent 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 fallbackComponent", () => { + 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 fallbackComponent for paragraph", () => { + const CustomP = ({ children }: React.PropsWithChildren) => ( +

{children}

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

+ const fallback = container.querySelector('[data-fallback="true"]'); + expect(fallback).toBeNull(); + }); + }); + + describe("HTML tags not in defaultComponents", () => { + it("uses fallbackComponent 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("built-in components still win with fallbackComponent set", () => { + it("still uses the built-in h1 (Tailwind classes) when fallbackComponent is set", () => { + const { container } = render( + + {"# Hello"} + + ); + + const h1 = container.querySelector("h1"); + expect(h1).toBeTruthy(); + expect(h1?.className).toContain("font-semibold"); + // Must not have been rendered via the fallback + expect(h1?.getAttribute("data-fallback")).toBeNull(); + expect(container.querySelector('[data-fallback="true"]')).toBeNull(); + }); + }); + + describe("backward compatibility", () => { + it("applies built-in Tailwind classes when fallbackComponent is absent", () => { + const { container } = render( + {"# Hello"} + ); + + const h1 = container.querySelector("h1"); + expect(h1).toBeTruthy(); + expect(h1?.className).toContain("font-semibold"); + }); + + it("does not add data-fallback when fallbackComponent is absent", () => { + const { container } = render( + {"Hello **world**"} + ); + + const fallback = container.querySelector('[data-fallback="true"]'); + expect(fallback).toBeNull(); + }); + }); + + describe("streaming mode", () => { + it("applies fallbackComponent 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 fallbackComponent", () => { + 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..c207eaba 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,31 @@ export type StreamdownProps = Options & { linkSafety?: LinkSafetyConfig; /** Custom tags to allow through sanitization with their permitted attributes */ allowedTags?: AllowedTags; + /** + * Fallback component for HTML tags or `allowedTags` entries that have no + * matching key in the `components` map. Built-in and explicit `components` + * entries always win — this does not replace the default Tailwind renderers. + * + * When set, it applies to: + * - Custom tags declared via `allowedTags` that have no matching key in + * `components`. + * - Standard HTML tags absent from both the built-in map and `components` + * (e.g. ``, ``, `

`, `
`). + * + * @example + * ```tsx + * // Render missing map entries / allowedTags via a pass-through + * + * createElement(node!.tagName, props, children) + * } + * > + * {markdown} + * + * ``` + */ + fallbackComponent?: 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 +485,7 @@ export const Streamdown = memo( linkSafety = defaultLinkSafetyConfig, lineNumbers = true, allowedTags, + fallbackComponent, literalTagContent, translations, icons: iconOverrides, @@ -646,13 +681,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 +699,56 @@ export const Streamdown = memo( }; } - return merged; - }, [components]); + if (fallbackComponent) { + // Eagerly register fallbackComponent 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] = fallbackComponent; + } + } + } + + // Wrap in a Proxy so any other tag not explicitly covered (e.g. HTML + // tags absent from defaultComponents like , ,
) + // also uses fallbackComponent 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: fallbackComponent, + 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 fallbackComponent; + } + return Reflect.get(target, prop, receiver); + }, + }); + } + + return merged as Components; + }, [components, fallbackComponent, allowedTags]); // Merge plugin remark plugins (math, cjk) // Order: CJK before -> default (remarkGfm) -> CJK after -> math @@ -865,6 +950,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.fallbackComponent === nextProps.fallbackComponent ); Streamdown.displayName = "Streamdown";