Current section

Files

Jump to
mdex examples streaming.exs
Raw

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