Packages
mdex
0.14.2
0.14.2
0.14.1
0.14.0
0.13.5
0.13.4
0.13.3
0.13.2
0.13.1
0.13.0
0.12.5
retired
0.12.4
0.12.3
0.12.2
0.12.1
0.12.0
0.11.7
0.11.6
0.11.5
0.11.4
0.11.3
0.11.2
0.11.1
0.11.0
0.10.0
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.6
0.8.5
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.2
0.6.1
0.6.0
0.5.0
0.4.3
0.4.2
0.4.1
0.4.0
0.3.3
0.3.2
0.3.1
0.3.0
0.2.0
0.1.18
0.1.17
0.1.16
0.1.15
0.1.14
0.1.13
0.1.12
0.1.11
0.1.10
0.1.9
0.1.8
0.1.7
0.1.6
0.1.5
0.1.4
0.1.3
0.1.2
0.1.1
0.1.0
Fast and extensible Markdown for Elixir
Current section
Files
Jump to
Current section
Files
examples/streaming.exs
mdex_path = System.get_env("MDEX_PATH", Path.expand("..", __DIR__))
Mix.install(
[
{:mdex, path: mdex_path},
{:lumis, "~> 0.10"},
{:lumis_wasm_elixir, "~> 0.26"},
{:lumis_wasm_bash, "~> 0.26"},
{:phoenix_playground, "~> 0.1.9"},
{:req, "~> 0.7.4"}
],
config: [mdex_native: [syntax_highlighter: :lumis]],
lockfile: Path.join(mdex_path, "mix.lock")
)
defmodule MDExStreamingDemo do
use Phoenix.LiveView
@default_url "https://raw.githubusercontent.com/leandrocp/mdex/main/README.md"
@display_chunk_bytes 64
@activity_limit 24
@mdex_options [
extension: [
alerts: true,
autolink: true,
footnotes: true,
shortcodes: true,
strikethrough: true,
table: true,
tasklist: true
],
parse: [relaxed_autolinks: true, relaxed_tasklist_matching: true],
render: [unsafe: true],
syntax_highlight: [
engine: :lumis,
opts: [
formatter: {:html_multi_themes, themes: [light: "github_light", dark: "github_dark"], default_theme: "light-dark()"}
]
]
]
@pipeline_markdown """
```elixir
Req.get!(url, into: :self).body
|> MDEx.stream(options)
|> Enum.each(&send(live_view, {:markdown_chunk, &1}))
# In the LiveView process:
stream_insert(socket, :markdown, chunk)
```
"""
@impl Phoenix.LiveView
def mount(params, _session, socket) do
url = Map.get(params, "url", @default_url)
live_view_memory = process_memory()
{:ok,
socket
|> stream_configure(:markdown, dom_id: fn {id, _document} -> "markdown-#{id}" end)
|> stream(:markdown, [])
|> assign(
url: url,
delay_ms: 25,
auto_scroll: true,
status: :idle,
waiting_producer: nil,
error: nil,
run_id: nil,
received_bytes: 0,
received_chunks: 0,
markdown_updates: 0,
rendered_chunks: 0,
index_activity: [],
current_source_chunk: nil,
producer_memory: 0,
producer_peak_memory: 0,
live_view_memory: live_view_memory,
live_view_peak_memory: live_view_memory,
display_chunk_bytes: @display_chunk_bytes,
pipeline_html: MDEx.to_html!(@pipeline_markdown, @mdex_options)
)}
end
@impl Phoenix.LiveView
def render(assigns) do
~H"""
<style>
:root {
color-scheme: light dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
body { margin: 0; background: #f5f7fb; color: #172033; }
* { box-sizing: border-box; }
button, input { font: inherit; }
code { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; }
.page { min-height: 100vh; padding: 2rem 1rem 4rem; }
.shell { width: min(1280px, 100%); margin: 0 auto; }
.hero { margin-bottom: 1.25rem; }
.hero h1 { margin: 0 0 .4rem; font-size: clamp(2rem, 5vw, 3.5rem); letter-spacing: -.05em; }
.hero p { margin: 0; color: #5d667a; font-size: 1.05rem; }
.card { background: #fff; border: 1px solid #dfe4ee; border-radius: 16px; box-shadow: 0 14px 40px rgba(25, 38, 71, .08); }
.controls { padding: 1rem; margin-bottom: 1rem; }
.url-input { width: 100%; min-width: 0; border: 1px solid #cbd3e1; border-radius: 10px; padding: .72rem .85rem; background: #fff; color: #172033; }
.button { border: 0; border-radius: 10px; padding: .72rem 1rem; cursor: pointer; font-weight: 700; }
.button-primary { color: #fff; background: #4f46e5; }
.button-secondary { color: #313a4d; background: #e9edf5; }
.button[disabled] { cursor: not-allowed; opacity: .5; }
.auto-scroll { display: inline-flex; align-items: center; gap: .55rem; width: fit-content; margin-top: 1rem; cursor: pointer; }
.auto-scroll input { width: 1rem; height: 1rem; accent-color: #4f46e5; }
.preview-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(260px, 300px); gap: 1rem; align-items: start; }
.diagnostics { position: sticky; top: 1rem; max-height: calc(100vh - 2rem); overflow-y: auto; }
.run-controls { position: sticky; top: 0; z-index: 1; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .5rem; padding: .85rem; border-bottom: 1px solid #e3e7ef; border-radius: 15px 15px 0 0; background: #fff; }
.sticky-pace { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr auto; align-items: center; gap: .25rem .5rem; color: #465066; }
.sticky-pace label { font-size: .72rem; font-weight: 650; }
.sticky-pace output { font-size: .7rem; font-variant-numeric: tabular-nums; }
.sticky-pace input { grid-column: 1 / -1; width: 100%; accent-color: #4f46e5; }
.run-controls .button-primary { grid-column: 1 / -1; }
.telemetry { padding: .85rem; color: #5d667a; }
.status { display: flex; flex-wrap: wrap; gap: .5rem 1rem; align-items: center; font-size: .9rem; }
.status strong { color: #172033; }
.status-dot { width: .55rem; height: .55rem; border-radius: 50%; background: #98a2b4; }
.status-dot.streaming { background: #16a34a; box-shadow: 0 0 0 .3rem rgba(22, 163, 74, .13); animation: pulse 1.2s infinite; }
.status-dot.paused { background: #d97706; }
.status-dot.complete { background: #4f46e5; }
.metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .55rem; margin: .75rem 0 0; }
.metric { min-width: 0; padding: .6rem; border: 1px solid #e3e7ef; border-radius: 9px; background: #f8f9fc; }
.metric dt { margin-bottom: .25rem; color: #737d90; font-size: .72rem; font-weight: 750; letter-spacing: .07em; text-transform: uppercase; }
.metric dd { margin: 0; color: #172033; font-size: .9rem; font-weight: 750; font-variant-numeric: tabular-nums; }
.metric small { display: block; margin-top: .18rem; color: #737d90; font-size: .68rem; }
.metrics-note { margin: .7rem 0 0; font-size: .7rem; }
.error { padding: .8rem 1rem; margin-bottom: 1rem; border: 1px solid #fecaca; border-radius: 12px; background: #fef2f2; color: #991b1b; }
.engine-activity { padding: .7rem .85rem .85rem; border-top: 1px solid #e3e7ef; color: #737d90; font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; font-size: .7rem; }
.engine-heading { display: flex; align-items: center; justify-content: space-between; gap: .5rem; }
.engine-activity strong { color: #465066; font-size: .72rem; }
.engine-source { min-width: 0; margin-top: .55rem; padding: .45rem .5rem; border: 1px solid #e3e7ef; border-radius: 6px; background: #f8f9fc; }
.engine-source span { display: block; margin-bottom: .25rem; color: #8a93a5; font-size: .62rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
.engine-source code { display: block; overflow-wrap: anywhere; color: #465066; font-size: .66rem; line-height: 1.35; white-space: pre-wrap; }
.engine-events { display: flex; flex-wrap: wrap; gap: .25rem; min-width: 0; max-height: 7rem; margin-top: .55rem; overflow-y: auto; }
.engine-token { padding: .12rem .3rem; border-radius: 4px; font-size: .68rem; line-height: 1.25; }
.engine-token.insert { color: #166534; background: #dcfce7; }
.engine-token.replace { color: #3730a3; background: #e0e7ff; }
.engine-legend { white-space: nowrap; color: #8a93a5; font-size: .65rem; }
.output { padding: clamp(1.1rem, 4vw, 2.5rem); min-height: 24rem; overflow-wrap: anywhere; }
.empty { display: grid; place-items: center; min-height: 20rem; color: #7a8498; text-align: center; }
.markdown-chunk { display: contents; }
.markdown-body { line-height: 1.7; }
.markdown-body h1, .markdown-body h2, .markdown-body h3 { line-height: 1.25; letter-spacing: -.025em; margin: 1.5em 0 .55em; }
.markdown-body h1:first-child, .markdown-body h2:first-child { margin-top: 0; }
.markdown-body a { color: #4338ca; }
.markdown-body img { max-width: 100%; }
.markdown-body blockquote { margin-left: 0; padding-left: 1rem; border-left: 4px solid #a5b4fc; color: #566078; }
.markdown-body code { padding: .12rem .35rem; border-radius: 5px; background: #eef1f7; }
.markdown-body pre { overflow-x: auto; padding: 1rem; border-radius: 10px; background: #111827; color: #e5e7eb; }
.markdown-body pre code { padding: 0; background: transparent; }
.markdown-body table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; }
.markdown-body th, .markdown-body td { padding: .5rem .7rem; border: 1px solid #d7dce6; text-align: left; }
.markdown-body hr { margin: 2rem 0; border: 0; border-top: 1px solid #d7dce6; }
.how-it-works { margin-bottom: 1rem; overflow: hidden; color: #5d667a; }
.how-it-works summary { padding: 1rem 1.25rem; cursor: pointer; color: #313a4d; font-size: 1.05rem; font-weight: 750; }
.how-it-content { padding: 0 1.25rem 1.25rem; border-top: 1px solid #e3e7ef; }
.how-it-works p { margin: 1rem 0; }
.how-it-works pre { overflow-x: auto; margin: 0; }
@keyframes pulse { 50% { opacity: .55; } }
@media (max-width: 900px) {
.page { padding-bottom: 8rem; }
.shell { width: min(760px, 100%); }
.preview-layout { grid-template-columns: 1fr; }
.diagnostics { position: static; max-height: none; overflow: visible; }
.run-controls { position: fixed; top: auto; right: 1rem; bottom: 1rem; left: 1rem; grid-template-columns: repeat(3, minmax(0, 1fr)); border: 1px solid #e3e7ef; border-radius: 12px; box-shadow: 0 14px 40px rgba(25, 38, 71, .18); }
.run-controls .button-primary { grid-column: auto; }
}
@media (prefers-color-scheme: dark) {
body { background: #0d1320; color: #e7eaf0; }
.hero p, .telemetry, .sticky-pace, .how-it-works { color: #aeb6c7; }
.card { background: #151d2c; border-color: #2a3447; box-shadow: none; }
.url-input { background: #0f1726; border-color: #354057; color: #e7eaf0; }
.button-secondary { color: #d8dce5; background: #2a3447; }
.run-controls { border-color: #303b50; background: #151d2c; }
.status strong, .metric dd, .how-it-works summary { color: #e7eaf0; }
.how-it-content { border-color: #303b50; }
.metric { border-color: #303b50; background: #101827; }
.metric dt, .metric small { color: #98a3b7; }
.engine-activity { border-color: #303b50; color: #8f99ac; }
.engine-activity strong { color: #c8ced9; }
.engine-source { border-color: #303b50; background: #101827; }
.engine-source code { color: #c8ced9; }
.engine-token.insert { color: #bbf7d0; background: #12351f; }
.engine-token.replace { color: #c7d2fe; background: #252b59; }
.error { border-color: #7f1d1d; background: #3b1118; color: #fecaca; }
.markdown-body a { color: #a5b4fc; }
.markdown-body blockquote { color: #b5bdcd; }
.markdown-body code { background: #273145; }
.markdown-body th, .markdown-body td { border-color: #3a4559; }
.markdown-body hr { border-color: #3a4559; }
.empty { color: #8f99ac; }
}
</style>
<main class="page">
<div class="shell">
<header class="hero">
<h1>MDEx streaming playground</h1>
<p>Stream a remote Markdown document through Req, MDEx, and Phoenix LiveView.</p>
</header>
<section class="card controls">
<form id="url-form" phx-submit="start" class="url-form">
<input
class="url-input"
type="url"
name="url"
value={@url}
aria-label="Markdown URL"
placeholder="https://example.com/document.md"
required
/>
</form>
<form id="auto-scroll-form" phx-change="auto_scroll">
<label class="auto-scroll" for="auto-scroll">
<input type="hidden" name="auto_scroll" value="false" />
<input
id="auto-scroll"
type="checkbox"
name="auto_scroll"
value="true"
checked={@auto_scroll}
/>
Auto-scroll as chunks arrive
</label>
</form>
</section>
<details id="how-it-works" class="card how-it-works">
<summary>How it works</summary>
<div class="how-it-content">
<p>Req reads source chunks. MDEx updates keyed AST chunks. LiveView inserts or replaces the matching DOM child.</p>
<div class="markdown-body">{Phoenix.HTML.raw(@pipeline_html)}</div>
</div>
</details>
<div :if={@error} class="error" role="alert">{@error}</div>
<div class="preview-layout">
<div :if={@status == :idle} class="card empty">
<p>Press <strong>Stream URL</strong> to fetch the MDEx README, or enter another Markdown URL.</p>
</div>
<article
:if={@status != :idle}
id="markdown-output"
class="card output markdown-body"
phx-update="stream"
phx-hook="AutoScroll"
data-auto-scroll={to_string(@auto_scroll)}
>
<div
:for={{dom_id, {_id, document}} <- @streams.markdown}
id={dom_id}
class="markdown-chunk"
phx-hook="ChunkReveal"
>
{Phoenix.HTML.raw(MDEx.to_html!(document))}
</div>
</article>
<aside class="card diagnostics" aria-label="Streaming diagnostics">
<nav class="run-controls" aria-label="Stream controls">
<form id="delay-form" phx-change="delay" class="sticky-pace">
<label for="delay-ms">Delay per {@display_chunk_bytes}-byte chunk</label>
<output for="delay-ms">{@delay_ms} ms</output>
<input
id="delay-ms"
type="range"
name="delay_ms"
min="0"
max="1000"
step="10"
value={@delay_ms}
/>
</form>
<button class="button button-primary" type="submit" form="url-form">
{if @status in [:streaming, :paused], do: "Restart", else: "Stream URL"}
</button>
<button
class="button button-secondary"
type="button"
phx-click={if @status == :paused, do: "resume", else: "pause"}
disabled={@status not in [:streaming, :paused]}
>
{if @status == :paused, do: "Resume", else: "Pause"}
</button>
<button
class="button button-secondary"
type="button"
phx-click="stop"
disabled={@status not in [:streaming, :paused]}
>
Stop
</button>
</nav>
<section id="stream-metrics" class="telemetry" aria-live="polite">
<div class="status">
<span class={["status-dot", Atom.to_string(@status)]}></span>
<strong>Run data</strong>
<span>{status_label(@status)}</span>
</div>
<dl class="metrics">
<div class="metric">
<dt>Req source</dt>
<dd id="source-metrics">{format_bytes(@received_bytes)}</dd>
<small>{@received_chunks} chunks · paced at {@display_chunk_bytes} bytes</small>
</div>
<div class="metric">
<dt>MDEx</dt>
<dd id="mdex-metrics">{@markdown_updates} updates</dd>
<small>{max(@markdown_updates - @rendered_chunks, 0)} replacements</small>
</div>
<div class="metric">
<dt>LiveView DOM</dt>
<dd id="dom-metrics">{@rendered_chunks} chunks</dd>
<small>ordered ids · any id can be replaced</small>
</div>
<div class="metric">
<dt>Producer memory</dt>
<dd id="producer-memory">{format_bytes(@producer_memory)}</dd>
<small>peak {format_bytes(@producer_peak_memory)}</small>
</div>
<div class="metric">
<dt>LiveView memory</dt>
<dd id="live-view-memory">{format_bytes(@live_view_memory)}</dd>
<small>peak {format_bytes(@live_view_peak_memory)}</small>
</div>
</dl>
<p class="metrics-note">
Memory is measured for each BEAM process. The browser keeps the latest value for each id. MDEx keeps the cumulative source and the latest keyed AST nodes.
</p>
</section>
<section id="engine-activity" class="engine-activity" aria-label="MDEx engine activity">
<div class="engine-heading">
<strong>Engine activity</strong>
<span class="engine-legend">+ insert · ~ replace</span>
</div>
<div class="engine-source">
<span>Markdown chunk {@received_chunks}</span>
<code id="current-source-chunk">{format_source_chunk(@current_source_chunk)}</code>
</div>
<div class="engine-events">
<span :if={@index_activity == []}>Waiting for indexed chunks</span>
<code
:for={{operation, id} <- Enum.reverse(@index_activity)}
class={["engine-token", Atom.to_string(operation)]}
>
{index_token(operation, id)}
</code>
</div>
</section>
</aside>
</div>
</div>
</main>
<script phx-no-curly-interpolation>
window.hooks.ChunkReveal = {
mounted() {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
for (const element of this.el.children) {
element.animate(
[
{ opacity: 0, transform: "translateY(4px)" },
{ opacity: 1, transform: "translateY(0)" }
],
{ duration: 180, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: "backwards" }
)
}
}
}
window.hooks.AutoScroll = {
mounted() { this.scrollToLatest() },
updated() { this.scrollToLatest() },
scrollToLatest() {
if (this.el.dataset.autoScroll === "true") {
window.requestAnimationFrame(() => {
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "smooth" })
})
}
}
}
</script>
"""
end
@impl Phoenix.LiveView
def handle_event("delay", %{"delay_ms" => delay_ms}, socket) do
{:noreply, assign(socket, :delay_ms, parse_delay(delay_ms))}
end
def handle_event("auto_scroll", %{"auto_scroll" => auto_scroll}, socket) do
{:noreply, assign(socket, :auto_scroll, auto_scroll == "true")}
end
def handle_event("pause", _params, %{assigns: %{status: :streaming}} = socket) do
{:noreply, assign(socket, :status, :paused)}
end
def handle_event("resume", _params, %{assigns: %{status: :paused}} = socket) do
if socket.assigns.waiting_producer do
send(socket.assigns.waiting_producer, {:continue, socket.assigns.run_id, socket.assigns.delay_ms})
end
{:noreply, assign(socket, status: :streaming, waiting_producer: nil)}
end
def handle_event(event, _params, socket) when event in ["pause", "resume"], do: {:noreply, socket}
def handle_event("start", %{"url" => raw_url}, socket) do
url = String.trim(raw_url)
live_view = self()
live_view_memory = process_memory()
run_id = make_ref()
socket =
socket
|> cancel_async(:markdown_fetch)
|> stream(:markdown, [], reset: true)
|> assign(
url: url,
run_id: run_id,
status: :streaming,
waiting_producer: nil,
error: nil,
received_bytes: 0,
received_chunks: 0,
markdown_updates: 0,
rendered_chunks: 0,
index_activity: [],
current_source_chunk: nil,
producer_memory: 0,
producer_peak_memory: 0,
live_view_memory: live_view_memory,
live_view_peak_memory: live_view_memory
)
|> start_async(:markdown_fetch, fn -> stream_url(url, live_view, run_id) end)
{:noreply, socket}
end
def handle_event("stop", _params, socket) do
{:noreply,
socket
|> cancel_async(:markdown_fetch)
|> assign(status: :stopped, waiting_producer: nil, run_id: nil)}
end
@impl Phoenix.LiveView
def handle_info(
{:pace_chunk, run_id, producer, chunk, producer_memory},
%{assigns: %{run_id: run_id}} = socket
) do
socket =
socket
|> update(:received_bytes, &(&1 + byte_size(chunk)))
|> update(:received_chunks, &(&1 + 1))
|> assign(:current_source_chunk, chunk)
|> put_memory(:producer, producer_memory)
|> put_memory(:live_view, process_memory())
if socket.assigns.status == :paused do
{:noreply, assign(socket, :waiting_producer, producer)}
else
send(producer, {:continue, run_id, socket.assigns.delay_ms})
{:noreply, socket}
end
end
def handle_info({:pace_chunk, _run_id, _producer, _chunk, _memory}, socket), do: {:noreply, socket}
def handle_info(
{:markdown_chunk, run_id, {id, _document} = chunk, producer_memory},
%{assigns: %{run_id: run_id}} = socket
) do
{:noreply,
socket
|> stream_insert(:markdown, chunk)
|> update(:markdown_updates, &(&1 + 1))
|> put_index_activity(id)
|> update(:rendered_chunks, &max(&1, id + 1))
|> put_memory(:producer, producer_memory)
|> put_memory(:live_view, process_memory())}
end
def handle_info({:markdown_chunk, _run_id, _chunk, _memory}, socket), do: {:noreply, socket}
@impl Phoenix.LiveView
def handle_async(:markdown_fetch, {:ok, {run_id, _status}}, %{assigns: %{run_id: run_id}} = socket) do
{:noreply, assign(socket, status: :complete, waiting_producer: nil, run_id: nil)}
end
def handle_async(:markdown_fetch, {:ok, {_run_id, _status}}, socket), do: {:noreply, socket}
def handle_async(:markdown_fetch, {:exit, {:shutdown, :cancel}}, socket), do: {:noreply, socket}
def handle_async(:markdown_fetch, {:exit, reason}, socket) do
{:noreply,
assign(socket,
status: :error,
waiting_producer: nil,
run_id: nil,
error: Exception.format_exit(reason)
)}
end
defp stream_url(url, live_view, run_id) do
response = Req.get!(url, into: :self)
response.body
|> rechunk(@display_chunk_bytes)
|> pace_with_live_view(live_view, run_id)
|> MDEx.stream(@mdex_options)
|> Enum.each(fn chunk ->
send(live_view, {:markdown_chunk, run_id, chunk, process_memory()})
end)
{run_id, :ok}
end
defp rechunk(chunks, bytes) do
Stream.flat_map(chunks, &split_binary(&1, bytes, []))
end
defp split_binary("", _bytes, chunks), do: Enum.reverse(chunks)
defp split_binary(binary, bytes, chunks) when byte_size(binary) <= bytes do
Enum.reverse([binary | chunks])
end
defp split_binary(binary, bytes, chunks) do
<<chunk::binary-size(^bytes), rest::binary>> = binary
split_binary(rest, bytes, [chunk | chunks])
end
defp pace_with_live_view(chunks, live_view, run_id) do
Stream.map(chunks, fn chunk ->
send(live_view, {:pace_chunk, run_id, self(), chunk, process_memory()})
receive do
{:continue, ^run_id, delay_ms} -> Process.sleep(delay_ms)
end
chunk
end)
end
defp parse_delay(value) do
value
|> String.to_integer()
|> min(1000)
|> max(0)
end
defp status_label(:idle), do: "Ready"
defp status_label(:streaming), do: "Streaming"
defp status_label(:paused), do: "Paused"
defp status_label(:complete), do: "Complete"
defp status_label(:stopped), do: "Stopped"
defp status_label(:error), do: "Error"
defp put_index_activity(socket, id) do
operation = if id < socket.assigns.rendered_chunks, do: :replace, else: :insert
update(socket, :index_activity, &Enum.take([{operation, id} | &1], @activity_limit))
end
defp index_token(:insert, id), do: "+#{id}"
defp index_token(:replace, id), do: "~#{id}"
defp format_source_chunk(nil), do: "Waiting for source"
defp format_source_chunk(chunk), do: inspect(chunk)
defp put_memory(socket, :producer, bytes) do
socket
|> assign(:producer_memory, bytes)
|> update(:producer_peak_memory, &max(&1, bytes))
end
defp put_memory(socket, :live_view, bytes) do
socket
|> assign(:live_view_memory, bytes)
|> update(:live_view_peak_memory, &max(&1, bytes))
end
defp process_memory do
{:memory, bytes} = Process.info(self(), :memory)
bytes
end
defp format_bytes(bytes) when bytes < 1_000, do: "#{bytes} B"
defp format_bytes(bytes) when bytes < 1_000_000, do: "#{Float.round(bytes / 1_000, 1)} kB"
defp format_bytes(bytes), do: "#{Float.round(bytes / 1_000_000, 1)} MB"
end
unless System.get_env("MDEX_STREAMING_EXAMPLE_NO_SERVER") == "1" do
open_browser? = System.get_env("MDEX_STREAMING_EXAMPLE_NO_BROWSER") != "1"
PhoenixPlayground.start(live: MDExStreamingDemo, open_browser: open_browser?)
end