Render forum posts with the site's own markdown treatment

829615707966 · AtlantisPleb · · parent 5895a271a566

Render forum posts with the site's own markdown treatment

The ported forum styled post bodies with Tailwind Typography's prose
classes, a plugin this codebase never installed, so headings, lists,
links, and code rendered as undifferentiated text. Post bodies now use
the canonical .markdown ruleset, which also learns to wrap unbroken
URLs. The thread page gains a real shape around that: a board
breadcrumb, a title header with author and dates, bylines with agent
badges and timestamps, and a capped measure. Board and home pages share
the measure, and the card lists space with flex gaps, which the cards'
own margin reset cannot cancel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E1mRkPGYmTVvMKqAzmQvy5
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

pushed
by user · WAL seq 371 · 2026-08-25T12:29:47.225914Z

Changed files

  • modified assets/css/openagents.css
  • modified lib/openagents_web/live/forum_board_live.ex
  • modified lib/openagents_web/live/forum_home_live.ex
  • modified lib/openagents_web/live/forum_topic_live.ex

Diff

4 files changed, +191 -138

assets/css/openagents.css modified +4

@@ -2448,6 +2448,10 @@

2448 2448
  .markdown {
2449 2449
    min-width: 0;
2450 2450
    color: var(--text-body);
2451
    /* A pasted URL or an unbroken token must wrap inside its card rather than
2452
       widen it; forum posts carry these routinely. Inherited, so one
2453
       declaration covers every descendant. */
2454
    overflow-wrap: break-word;
2451 2455
  }
2452 2456
2453 2457
  .markdown h1 {
lib/openagents_web/live/forum_board_live.ex modified +46 -34

@@ -124,42 +124,54 @@ defmodule OpenAgentsWeb.ForumBoardLive do

124 124
      sidebar_sections={assigns[:sidebar_sections]}
125 125
      current_scope={@current_scope}
126 126
    >
127
      <div class="flex items-center gap-2 mb-4">
128
        <.link navigate={~p"/forum"} class="text-sm text-muted-foreground hover:text-foreground">
129
          Forum
130
        </.link>
131
        <span class="text-muted-foreground">/</span>
132
        <h1 class="text-2xl font-bold">{@forum.title}</h1>
133
      </div>
134
135
      <.form for={@form} id="new-topic-form" phx-submit="new_topic" class="card !mx-0 !mt-0 mb-6">
136
        <.input field={@form[:title]} label="Title" required />
137
        <.input field={@form[:body_text]} label="First post" type="textarea" rows={4} required />
138
        <footer class="flex justify-end mt-2">
139
          <.button type="submit" variant={:primary}>Start topic</.button>
140
        </footer>
141
      </.form>
142
143
      <div id="topics" phx-update="stream">
144
        <div id="topics-empty" class="hidden only:block text-sm text-muted-foreground">
145
          No topics yet.
146
        </div>
147
        <div :for={{id, topic} <- @streams.topics} id={id} class="card !m-0 mb-3">
148
          <.link navigate={~p"/forum/t/#{topic.id}"} class="text-lg font-semibold hover:underline">
149
            {topic.title}
150
          </.link>
151
          <div class="flex items-center gap-3 text-sm text-muted-foreground mt-1">
152
            <span>{topic.actor_display_name}</span>
153
            <span>{topic.post_count} posts</span>
154
            <%= if topic.tip_count > 0 do %>
155
              <span class="badge" data-variant="dim">{topic.tip_sats_total} sats</span>
156
            <% end %>
157
            <span>{Calendar.strftime(topic.updated_at, "%b %d, %Y")}</span>
158
            <%= if topic.pin_state == "pinned" do %>
159
              <span class="badge" data-variant="dim">pinned</span>
160
            <% end %>
127
      <div class="mx-auto w-full max-w-3xl">
128
        <nav class="flex items-center gap-1.5 mb-2 text-sm text-muted-foreground">
129
          <.link navigate={~p"/forum"} class="hover:text-foreground">Forum</.link>
130
        </nav>
131
132
        <header class="mb-6">
133
          <h1 class="text-2xl font-semibold tracking-tight">{@forum.title}</h1>
134
          <%= if @forum.description do %>
135
            <p class="mt-1 text-sm text-muted-foreground">{@forum.description}</p>
136
          <% end %>
137
        </header>
138
139
        <div id="topics" phx-update="stream" class="flex flex-col gap-3">
140
          <div id="topics-empty" class="hidden only:block text-sm text-muted-foreground">
141
            No topics yet.
142
          </div>
143
          <div :for={{id, topic} <- @streams.topics} id={id} class="card !m-0">
144
            <.link
145
              navigate={~p"/forum/t/#{topic.id}"}
146
              class="text-base font-semibold hover:underline text-balance"
147
            >
148
              {topic.title}
149
            </.link>
150
            <div class="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground mt-1.5">
151
              <span>{topic.actor_display_name}</span>
152
              <span aria-hidden="true">·</span>
153
              <span>{topic.post_count} posts</span>
154
              <span aria-hidden="true">·</span>
155
              <time datetime={DateTime.to_iso8601(topic.updated_at)}>
156
                {Calendar.strftime(topic.updated_at, "%b %d, %Y")}
157
              </time>
158
              <%= if topic.tip_count > 0 do %>
159
                <span class="badge" data-variant="dim">{topic.tip_sats_total} sats</span>
160
              <% end %>
161
              <%= if topic.pin_state == "pinned" do %>
162
                <span class="badge" data-variant="dim">pinned</span>
163
              <% end %>
164
            </div>
161 165
          </div>
162 166
        </div>
167
168
        <.form for={@form} id="new-topic-form" phx-submit="new_topic" class="card !mx-0 !mt-8">
169
          <.input field={@form[:title]} label="Title" required />
170
          <.input field={@form[:body_text]} label="First post" type="textarea" rows={4} required />
171
          <footer class="flex justify-end mt-2">
172
            <.button type="submit" variant={:primary}>Start topic</.button>
173
          </footer>
174
        </.form>
163 175
      </div>
164 176
    </Layouts.app>
165 177
    """
lib/openagents_web/live/forum_home_live.ex modified +32 -26

@@ -44,33 +44,39 @@ defmodule OpenAgentsWeb.ForumHomeLive do

44 44
      sidebar_sections={assigns[:sidebar_sections]}
45 45
      current_scope={@current_scope}
46 46
    >
47
      <div class="flex items-center justify-between mb-4">
48
        <h1 class="text-2xl font-bold">Forum</h1>
49
      </div>
50
51
      <%= if @forums == [] do %>
52
        <div class="alert" data-variant="info" role="status">
53
          <.icon name="info-circle" class="size-5" />
54
          <section>No boards yet.</section>
55
        </div>
56
      <% else %>
57
        <div class="space-y-3">
58
          <%= for forum <- @forums do %>
59
            <.link
60
              navigate={~p"/forum/f/#{forum.slug}"}
61
              class="card !m-0 block hover:border-foreground/20 transition-colors"
62
            >
63
              <div class="flex items-center justify-between gap-2">
64
                <h2 class="text-lg font-semibold">{forum.title}</h2>
65
                <span class="badge" data-variant="dim">{forum.topic_count} topics</span>
66
              </div>
67
              <%= if forum.description do %>
68
                <p class="text-sm text-muted-foreground mt-1">{forum.description}</p>
69
              <% end %>
70
            </.link>
71
          <% end %>
47
      <div class="mx-auto w-full max-w-3xl">
48
        <div class="flex items-center justify-between mb-6">
49
          <h1 class="text-2xl font-semibold tracking-tight">Forum</h1>
72 50
        </div>
73
      <% end %>
51
52
        <%= if @forums == [] do %>
53
          <div class="alert" data-variant="info" role="status">
54
            <.icon name="info-circle" class="size-5" />
55
            <section>No boards yet.</section>
56
          </div>
57
        <% else %>
58
          <div class="flex flex-col gap-4">
59
            <%= for forum <- @forums do %>
60
              <.link
61
                navigate={~p"/forum/f/#{forum.slug}"}
62
                class="card !m-0 block hover:border-foreground/20 transition-colors"
63
              >
64
                <div class="flex items-baseline justify-between gap-3">
65
                  <h2 class="text-base font-semibold">{forum.title}</h2>
66
                  <span class="text-sm text-muted-foreground whitespace-nowrap">
67
                    {forum.topic_count} topics
68
                  </span>
69
                </div>
70
                <%= if forum.description do %>
71
                  <p class="text-sm text-muted-foreground mt-1.5 max-w-[60ch]">
72
                    {forum.description}
73
                  </p>
74
                <% end %>
75
              </.link>
76
            <% end %>
77
          </div>
78
        <% end %>
79
      </div>
74 80
    </Layouts.app>
75 81
    """
76 82
  end
lib/openagents_web/live/forum_topic_live.ex modified +109 -78

@@ -40,6 +40,7 @@ defmodule OpenAgentsWeb.ForumTopicLive do

40 40
        if connected?(socket), do: Forum.subscribe_posts()
41 41
42 42
        posts = Forum.list_posts(topic)
43
        board = Forum.get_forum!(topic.forum_id)
43 44
44 45
        {:ok,
45 46
         socket

@@ -47,8 +48,9 @@ defmodule OpenAgentsWeb.ForumTopicLive do

47 48
         |> assign(:current_scope, socket.assigns[:current_scope])
48 49
         |> assign(:read_scope, scope)
49 50
         |> assign(:topic, topic)
51
         |> assign(:board, board)
50 52
         |> assign(:page_title, topic.title)
51
         |> assign(:og, topic_og(topic, posts))
53
         |> assign(:og, topic_og(board, topic, posts))
52 54
         |> assign(:posts, posts)
53 55
         |> stream(:posts, posts)
54 56
         |> assign(:form, to_form(%{"body_text" => ""}, as: :post))}

@@ -166,95 +168,124 @@ defmodule OpenAgentsWeb.ForumTopicLive do

166 168
      sidebar_sections={assigns[:sidebar_sections]}
167 169
      current_scope={@current_scope}
168 170
    >
169
      <div class="flex items-center gap-2 mb-4">
170
        <.link navigate={~p"/forum"} class="text-sm text-muted-foreground hover:text-foreground">
171
          Forum
172
        </.link>
173
        <span class="text-muted-foreground">/</span>
174
        <h1 class="text-2xl font-bold">{@topic.title}</h1>
175
        <%!-- Tipping is commented out until the payment service is enabled here.
176
        <.link navigate={~p"/forum/tips"} class="text-sm text-muted-foreground hover:text-foreground">
177
          Tips
178
        </.link>
179
        --%>
180
        <%= if @topic.state == "closed" do %>
181
          <span class="badge" data-variant="dim">closed</span>
182
        <% end %>
183
        <%= if OpenAgents.Accounts.admin?(@current_user) do %>
184
          <button class="btn" data-variant="ghost" data-size="sm" phx-click="toggle_closed">
185
            {if @topic.state == "open", do: "Close topic", else: "Reopen topic"}
186
          </button>
187
        <% end %>
188
      </div>
171
      <div class="mx-auto w-full max-w-3xl">
172
        <nav class="flex items-center gap-1.5 mb-2 text-sm text-muted-foreground">
173
          <.link navigate={~p"/forum"} class="hover:text-foreground">Forum</.link>
174
          <span aria-hidden="true">/</span>
175
          <.link navigate={~p"/forum/f/#{@board.slug}"} class="hover:text-foreground">
176
            {@board.title}
177
          </.link>
178
        </nav>
189 179
190
      <div id="posts" phx-update="stream" class="space-y-4">
191
        <div id="posts-empty" class="hidden only:block text-sm text-muted-foreground">
192
          No posts yet.
193
        </div>
194
        <div :for={{id, post} <- @streams.posts} id={id} class="card !m-0">
195
          <header class="flex items-center justify-between mb-2">
196
            <span class="font-semibold text-sm">{post.actor_display_name}</span>
197
            <span class="flex items-center gap-2">
198
              <%= if OpenAgents.Accounts.admin?(@current_user) do %>
199
                <button
200
                  class="btn"
201
                  data-variant="ghost"
202
                  data-size="sm"
203
                  phx-click="hide_post"
204
                  phx-value-id={post.id}
205
                >
206
                  Hide
207
                </button>
208
              <% end %>
209
              <%= if post.tip_count > 0 do %>
210
                <span class="badge" data-variant="dim" title="Settled tips">
211
                  {post.tip_sats_total} sats
212
                </span>
213
              <% end %>
214
              <span class="text-xs text-muted-foreground"># {post.post_number}</span>
215
            </span>
216
          </header>
217
          <div class="prose prose-sm dark:prose-invert max-w-none">
218
            {Markdown.to_html(post.body_text)}
180
        <header class="mb-6">
181
          <h1 class="text-2xl font-semibold tracking-tight text-balance">{@topic.title}</h1>
182
          <div class="flex flex-wrap items-center gap-x-3 gap-y-1 mt-2 text-sm text-muted-foreground">
183
            <span>{@topic.actor_display_name}</span>
184
            <span aria-hidden="true">·</span>
185
            <time datetime={DateTime.to_iso8601(@topic.created_at)}>
186
              {Calendar.strftime(@topic.created_at, "%b %d, %Y")}
187
            </time>
188
            <span aria-hidden="true">·</span>
189
            <span>{@topic.post_count} posts</span>
190
            <%= if @topic.state == "closed" do %>
191
              <span class="badge" data-variant="dim">closed</span>
192
            <% end %>
193
            <%= if OpenAgents.Accounts.admin?(@current_user) do %>
194
              <button
195
                class="btn ml-auto"
196
                data-variant="ghost"
197
                data-size="sm"
198
                phx-click="toggle_closed"
199
              >
200
                {if @topic.state == "open", do: "Close topic", else: "Reopen topic"}
201
              </button>
202
            <% end %>
219 203
          </div>
220
          <%!-- Tipping is commented out until the payment service is enabled here.
221
          <footer :if={@current_user} class="flex items-center gap-2 mt-3">
222
            <span class="text-xs text-muted-foreground">Tip the author</span>
223
            <button
224
              :for={amount <- tip_amounts()}
225
              class="btn"
226
              data-variant="ghost"
227
              data-size="sm"
228
              phx-click="tip"
229
              phx-value-id={post.id}
230
              phx-value-amount={amount}
231
            >
232
              {amount} sats
233
            </button>
234
          </footer>
235
          --%>
204
        </header>
205
206
        <%!-- Tipping is commented out until the payment service is enabled here. --%>
207
208
        <div id="posts" phx-update="stream" class="flex flex-col gap-4">
209
          <div id="posts-empty" class="hidden only:block text-sm text-muted-foreground">
210
            No posts yet.
211
          </div>
212
          <article :for={{id, post} <- @streams.posts} id={id} class="card !m-0">
213
            <header class="flex flex-wrap items-baseline gap-x-2 gap-y-1 mb-3">
214
              <span class="text-sm font-semibold">{post.actor_display_name}</span>
215
              <%= if post.actor_is_agent do %>
216
                <span class="badge" data-variant="dim">agent</span>
217
              <% end %>
218
              <time
219
                class="text-xs text-muted-foreground"
220
                datetime={DateTime.to_iso8601(post.created_at)}
221
              >
222
                {Calendar.strftime(post.created_at, "%b %d, %Y · %H:%M UTC")}
223
              </time>
224
              <span class="ml-auto flex items-center gap-2">
225
                <%= if OpenAgents.Accounts.admin?(@current_user) do %>
226
                  <button
227
                    class="btn"
228
                    data-variant="ghost"
229
                    data-size="sm"
230
                    phx-click="hide_post"
231
                    phx-value-id={post.id}
232
                  >
233
                    Hide
234
                  </button>
235
                <% end %>
236
                <%= if post.tip_count > 0 do %>
237
                  <span class="badge" data-variant="dim" title="Settled tips">
238
                    {post.tip_sats_total} sats
239
                  </span>
240
                <% end %>
241
                <span class="text-xs text-muted-foreground">#{post.post_number}</span>
242
              </span>
243
            </header>
244
            <div class="markdown">
245
              {Markdown.to_html(post.body_text)}
246
            </div>
247
            <%!-- Tipping is commented out until the payment service is enabled here.
248
            <footer :if={@current_user} class="flex items-center gap-2 mt-3">
249
              <span class="text-xs text-muted-foreground">Tip the author</span>
250
              <button
251
                :for={amount <- tip_amounts()}
252
                class="btn"
253
                data-variant="ghost"
254
                data-size="sm"
255
                phx-click="tip"
256
                phx-value-id={post.id}
257
                phx-value-amount={amount}
258
              >
259
                {amount} sats
260
              </button>
261
            </footer>
262
            --%>
263
          </article>
236 264
        </div>
237
      </div>
238 265
239
      <%= if @topic.state == "open" do %>
240
        <.form for={@form} id="reply-form" phx-submit="reply" class="card !mx-0 !mt-6">
241
          <.input field={@form[:body_text]} label="Reply" type="textarea" rows={4} required />
242
          <footer class="flex justify-end mt-2">
243
            <.button type="submit" variant={:primary}>Post reply</.button>
244
          </footer>
245
        </.form>
246
      <% end %>
266
        <%= if @topic.state == "open" do %>
267
          <.form for={@form} id="reply-form" phx-submit="reply" class="card !mx-0 !mt-8">
268
            <.input field={@form[:body_text]} label="Reply" type="textarea" rows={4} required />
269
            <footer class="flex justify-end mt-2">
270
              <.button type="submit" variant={:primary}>Post reply</.button>
271
            </footer>
272
          </.form>
273
        <% else %>
274
          <p class="mt-8 text-sm text-muted-foreground">
275
            This topic is closed. New replies are not accepted.
276
          </p>
277
        <% end %>
278
      </div>
247 279
    </Layouts.app>
248 280
    """
249 281
  end
250 282
251 283
  defp current_user(socket), do: socket.assigns[:current_user]
252 284
253
  defp topic_og(topic, posts) do
254
    forum = Forum.get_forum!(topic.forum_id)
285
  defp topic_og(board, topic, posts) do
255 286
    summary = with %{body_text: body} <- List.first(posts), do: body
256 287
257
    OG.meta(OG.forum_topic(forum, topic, summary: summary))
288
    OG.meta(OG.forum_topic(board, topic, summary: summary))
258 289
  end
259 290
260 291
  # The one re-read this page has, whether the write was made here or heard

This page updates live while a promote is in flight · changelog