Markdown
Markdown as a model writes it — GFM, highlighted code, CJK emphasis — with maths and diagrams fetched only when the text contains them.
Refund policy
Refunds are prorated to the day. A workspace on the yearly plan that cancels in month four gets eight months back.
pnpm exec shadcn add @intelligo/ai-markdownInside an app made by intelligo create, or any shadcn project that names the registry.
built on
Nothing else from the registry.
npm packages it adds
streamdown · @streamdown/code · @streamdown/math · @streamdown/mermaid · @streamdown/cjk · katex
used by these pages
more in what it produces
Code block, Artifact, Motion, Citations, Image generation, Sidebar
Source
What shadcn add writes to components/ui/ai-markdown.tsx. It is yours from then on.
"use client";
/**
* Markdown as a model writes it: GFM, highlighted code, CJK-friendly
* emphasis and, when the text calls for them, maths and diagrams.
*
* KaTeX and Mermaid weigh over a megabyte between them, so neither ships
* with the page. Each is fetched the first time a `$$` block or a
* `mermaid` fence appears in any rendered text, and every `Markdown` on
* the page picks it up once it lands; until then the source shows as
* plain text or a code block.
*
* Images load only from the hosts in `imagePrefixes`, none by default. Model output can be steered by what it
* read — a page, a document, a tool result — and an image URL fetches on
* render, so an open policy would let that text send the reader's IP, and
* anything put in the URL, to any server; a shared chat would do it for
* every visitor. Pass the hosts a product trusts.
*/
import * as React from "react";
import { defaultComponents, defaultRehypePlugins, Streamdown } from "streamdown";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
import "katex/dist/katex.min.css";
type StreamdownProps = React.ComponentProps<typeof Streamdown>;
type Plugins = NonNullable<StreamdownProps["plugins"]>;
type OptionalPlugin = "math" | "mermaid";
const NEEDS: Record<OptionalPlugin, RegExp> = {
math: /\$\$|```math/,
mermaid: /```mermaid/,
};
const LOADERS: Record<OptionalPlugin, () => Promise<unknown>> = {
math: () => import("@streamdown/math").then((m) => m.math),
mermaid: () => import("@streamdown/mermaid").then((m) => m.mermaid),
};
const loaded: Partial<Record<OptionalPlugin, unknown>> = {};
const requested = new Set<OptionalPlugin>();
const listeners = new Set<() => void>();
let version = 0;
function request(name: OptionalPlugin) {
if (requested.has(name)) return;
requested.add(name);
LOADERS[name]()
.then((plugin) => {
loaded[name] = plugin;
version += 1;
for (const notify of listeners) notify();
})
// A failed fetch leaves the source readable as text; the next
// render that needs the plugin asks again.
.catch(() => requested.delete(name));
}
function subscribe(notify: () => void) {
listeners.add(notify);
return () => {
listeners.delete(notify);
};
}
const getVersion = () => version;
const getServerVersion = () => 0;
// One object per combination, so Streamdown sees the same `plugins`
// across renders and only reparses when a plugin actually arrives.
const combinations = new Map<string, Plugins>();
function pluginsFor(math: boolean, mermaid: boolean): Plugins {
const key = `${math}:${mermaid}`;
let plugins = combinations.get(key);
if (!plugins) {
// The plugin packages type `Pluggable` against their own `unified`
// copy; the shapes are the ones Streamdown expects.
plugins = {
code,
cjk,
...(math ? { math: loaded.math } : {}),
...(mermaid ? { mermaid: loaded.mermaid } : {}),
} as unknown as Plugins;
combinations.set(key, plugins);
}
return plugins;
}
// Streamdown's own hardening plugin, re-optioned: its default lets
// every image and link through.
const [harden] = defaultRehypePlugins.harden as unknown as [
NonNullable<StreamdownProps["rehypePlugins"]>[number],
];
const rehypeFor = new Map<string, NonNullable<StreamdownProps["rehypePlugins"]>>();
function rehypePluginsFor(
imagePrefixes: readonly string[],
linkPrefixes: readonly string[]
) {
const key = `${imagePrefixes.join(" ")}\u0000${linkPrefixes.join(" ")}`;
let plugins = rehypeFor.get(key);
if (!plugins) {
plugins = [
defaultRehypePlugins.raw!,
defaultRehypePlugins.sanitize!,
[
harden,
{
// Resolves a relative URL, which then matches no allowed host.
defaultOrigin: "https://relative.invalid",
allowedImagePrefixes: [...imagePrefixes],
allowDataImages: true,
allowedLinkPrefixes: [...linkPrefixes],
// http, https and mailto are always allowed; beyond them only
// tel:, so a link cannot open another app's custom scheme.
allowedProtocols: ["tel:"],
},
],
] as NonNullable<StreamdownProps["rehypePlugins"]>;
rehypeFor.set(key, plugins);
}
return plugins;
}
/**
* Markdown's own link: it confirms before opening another site. A
* `components.a` override that wraps some links (citations, say) renders
* the rest with this, so they keep that confirmation.
*/
export const MarkdownLink = defaultComponents.a;
const NO_IMAGE_HOSTS: readonly string[] = [];
const ANY_LINK_HOST: readonly string[] = ["*"];
export type MarkdownProps = Omit<StreamdownProps, "plugins"> & {
/** URL prefixes images may load from, e.g. `https://cdn.example.com/`. */
imagePrefixes?: readonly string[];
/**
* URL prefixes links may point at; any http(s) address by default. A
* product that only wants its own and its sources' pages linked names
* them; a link elsewhere then shows as text.
*/
linkPrefixes?: readonly string[];
};
export function Markdown({
children,
imagePrefixes = NO_IMAGE_HOSTS,
linkPrefixes = ANY_LINK_HOST,
...props
}: MarkdownProps) {
const text = typeof children === "string" ? children : "";
const needsMath = NEEDS.math.test(text);
const needsMermaid = NEEDS.mermaid.test(text);
React.useEffect(() => {
if (needsMath) request("math");
if (needsMermaid) request("mermaid");
}, [needsMath, needsMermaid]);
React.useSyncExternalStore(subscribe, getVersion, getServerVersion);
return (
<Streamdown
plugins={pluginsFor(
needsMath && "math" in loaded,
needsMermaid && "mermaid" in loaded
)}
rehypePlugins={rehypePluginsFor(imagePrefixes, linkPrefixes)}
{...props}
>
{children}
</Streamdown>
);
}Ready to try it? Three commands and the app runs against a stub model.