`
* `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"""
"""
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