defmodule PhoenixStreamdown do @moduledoc """ Streaming markdown renderer for Phoenix LiveView. Renders LLM-streamed markdown with proper handling of incomplete syntax, block-level memoization, and minimal DOM updates. ## Usage use PhoenixStreamdown <.markdown content={@response} streaming={@streaming?} /> Or without importing: ## How it works 1. `PhoenixStreamdown.Remend` — auto-closes incomplete markdown syntax (`**bold` → `**bold**`, unclosed code fences, partial links) 2. `PhoenixStreamdown.Blocks` — splits markdown into independent blocks 3. [MDEx](https://hex.pm/packages/mdex) — renders each block to HTML server-side (Rust-backed) 4. LiveView — diffs only the active block, skips the rest Completed blocks get `phx-update="ignore"` so LiveView skips them entirely. On a 56-block document, this means **~7x less server CPU** and **~460x smaller diffs** per token compared to re-rendering the full document each time. ## Animations Enable word-level streaming animations (like Vercel's Streamdown): <.markdown content={@response} streaming animate="fadeIn" /> Available animations: `fadeIn` (default), `blurIn`, `slideUp`. Include the CSS in your app: @import "../../deps/phoenix_streamdown/priv/static/phoenix_streamdown.css"; ## Customization ### Syntax highlighting theme <.markdown content={@md} theme="catppuccin_mocha" /> Available themes: `onedark` (default), `dracula`, `github_dark`, `github_light`, `catppuccin_mocha`, `nord`, `tokyonight_night`, `vscode_dark`, and [100+ more](https://lumis.sh). ### CSS classes <.markdown content={@md} class="prose" block_class="mb-4" /> ### Stable IDs Each component auto-generates a unique `id`. For completed messages in a list, pass a stable ID to preserve frozen blocks across re-renders: <.markdown content={msg.content} id={"msg-\#{msg.id}"} /> The streaming component doesn't need an explicit `id` — it's ephemeral. ### MDEx options Options are deep-merged with defaults, so you only override what you need: <.markdown content={@md} mdex_opts={[extension: [shortcodes: true]]} /> ## Why not just MDEx streaming? MDEx has its own `streaming: true` mode. PhoenixStreamdown adds: - **Block-level memoization** — only the last block re-renders per token - **Ready-made LiveView component** — drop in, pass content and streaming flag - **Remend** — strips partial links/images instead of rendering broken HTML - **Word-level animations** — fade-in/blur-in each new word as it streams """ @doc """ Imports the `markdown/1` component for use as `<.markdown />`. defmodule MyAppWeb.ChatLive do use MyAppWeb, :live_view use PhoenixStreamdown def render(assigns) do ~H\"\"\" <.markdown content={@response} streaming={@streaming?} /> \"\"\" end end """ defmacro __using__(_opts) do quote do import PhoenixStreamdown, only: [markdown: 1] end end use Phoenix.Component import Phoenix.HTML, only: [raw: 1] alias PhoenixStreamdown.Animate alias PhoenixStreamdown.Blocks alias PhoenixStreamdown.Remend @default_mdex_opts [ extension: [ strikethrough: true, table: true, autolink: true, tasklist: true ] ] @doc """ Renders markdown content with streaming support. ## Attributes * `content` — the markdown string to render * `streaming` — whether content is still being streamed (enables incomplete syntax completion) * `animate` — animation type for streaming: `"fadeIn"`, `"blurIn"`, `"slideUp"`, or `nil` to disable * `class` — CSS class for the wrapper `
` * `block_class` — CSS class for each block `
` * `id` — unique ID prefix (auto-generated; pass explicitly for stable IDs across re-renders) * `theme` — syntax highlighting theme name (default: `"onedark"`) * `mdex_opts` — options passed to `MDEx.to_html!/2` (merged with defaults) ## Examples """ attr :content, :string, default: "" attr :streaming, :boolean, default: false attr :animate, :string, default: nil attr :class, :any, default: nil attr :block_class, :any, default: nil attr :id, :string attr :theme, :string, default: "onedark" attr :mdex_opts, :list, default: [] def markdown(assigns) do assigns = assign_new(assigns, :id, fn -> "psd-#{System.unique_integer([:positive])}" end) completed = if assigns.streaming do Remend.complete(assigns.content || "") else assigns.content || "" end blocks = completed |> Blocks.parse() |> Enum.with_index() mdex_opts = build_mdex_opts(assigns.mdex_opts, assigns.theme) last_idx = length(blocks) - 1 animate? = assigns.streaming and is_binary(assigns.animate) and assigns.animate != "" rendered_blocks = if animate? do render_animated_blocks(blocks, mdex_opts, last_idx, assigns.id, assigns.animate) else Process.delete({__MODULE__, assigns.id}) Enum.map(blocks, fn {block, idx} -> {idx, render_block(block, mdex_opts), idx == last_idx} end) end assigns = assigns |> assign(:rendered_blocks, rendered_blocks) |> assign(:last_idx, last_idx) ~H"""
{raw(html)}
""" end defp render_animated_blocks(blocks, mdex_opts, last_idx, id, animation) do pdict_key = {__MODULE__, id} {prev_block_idx, prev_chars} = Process.get(pdict_key, {0, 0}) prev_chars = if prev_block_idx == last_idx, do: prev_chars, else: 0 Enum.map(blocks, fn {block, idx} -> html = render_block(block, mdex_opts) if idx == last_idx do {animated, new_chars} = Animate.animate_words(html, prev_chars, animation: animation) Process.put(pdict_key, {last_idx, new_chars}) {idx, animated, true} else {idx, html, false} end end) end defp build_mdex_opts(user_opts, theme) do theme_opts = [ syntax_highlight: [ formatter: {:html_inline, [theme: theme]} ] ] @default_mdex_opts |> deep_merge(theme_opts) |> deep_merge(user_opts) end defp deep_merge(base, override) do Keyword.merge(base, override, fn _key, v1, v2 -> if Keyword.keyword?(v1) and Keyword.keyword?(v2) do deep_merge(v1, v2) else v2 end end) end defp render_block(block, mdex_opts) do MDEx.to_html!(block, mdex_opts) rescue _ -> "

#{Phoenix.HTML.html_escape(block)}

" end end