From 60fa3bcb5cd04fbe040f659087f06e2c925dc10e Mon Sep 17 00:00:00 2001 From: Matt de Courcelle Date: Tue, 26 May 2026 01:45:08 -0500 Subject: [PATCH] feat(live_ui): :markdown_viewer renders markdown in format='rendered' (Tier 2 substrate gap proposal) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Earmark.as_html (escape: true) + HtmlSanitizeEx.markdown_html for the rendered format;
 with HTML-escape for raw. Uses :format attr —
LiveUi.Widget reserves :mode in build_render_assigns/1's Map.drop list,
so :mode-named attrs are silently always-default.

Co-Authored-By: Claude Opus 4.7 (1M context) 
---
 mix.lock                                      |   3 +
 .../lib/live_ui/widgets/markdown_viewer.ex    |  62 +++++++-
 packages/live_ui/mix.exs                      |   2 +
 .../live_ui/widgets/markdown_viewer_test.exs  | 144 ++++++++++++++++++
 4 files changed, 207 insertions(+), 4 deletions(-)
 create mode 100644 packages/live_ui/test/live_ui/widgets/markdown_viewer_test.exs

diff --git a/mix.lock b/mix.lock
index 368bb058..2d22d855 100644
--- a/mix.lock
+++ b/mix.lock
@@ -11,6 +11,7 @@
   "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
   "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
   "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"},
+  "earmark": {:hex, :earmark, "1.4.48", "5f41e579d85ef812351211842b6e005f6e0cef111216dea7d4b9d58af4608434", [:mix], [], "hexpm", "a461a0ddfdc5432381c876af1c86c411fd78a25790c75023c7a4c035fdc858f9"},
   "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
   "ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
   "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"},
@@ -20,6 +21,7 @@
   "fuse": {:hex, :fuse, "2.5.0", "71afa90be21da4e64f94abba9d36472faa2d799c67fedc3bd1752a88ea4c4753", [:rebar3], [], "hexpm", "7f52a1c84571731ad3c91d569e03131cc220ebaa7e2a11034405f0bac46a4fef"},
   "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"},
   "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
+  "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.5.1", "70d7a817eca4850b330361e1f85ca02422a25d6564fc43dd0915dadac55a16f8", [:mix], [{:mochiweb, "~> 2.15 or ~> 3.1", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "c32e0a7f1c479ee4f387a3468b3f27a89715a96e71ee4f0d6a7a9d5658a083ef"},
   "igniter": {:hex, :igniter, "0.7.6", "687d622c735e020f13cf480c83d0fce1cc899f4fbed547f5254b960ea82d3525", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "424f41a41273fce0f7424008405ee073b5bd06359ca9396e841f83a669c01619"},
   "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"},
   "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
@@ -28,6 +30,7 @@
   "memento": {:hex, :memento, "0.5.0", "9c6943aa9c4c792b19ab2862159e2f7f5b7ec011801e3270b0bf220f15cb6aed", [:mix], [], "hexpm", "f4c2108737640a0e9d3cd2f230f46863d746d5ab333b0ecd28619a2ae330d881"},
   "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
   "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
+  "mochiweb": {:hex, :mochiweb, "3.3.0", "2898ad0bfeee234e4cbae623c7052abc3ff0d73d499ba6e6ffef445b13ffd07a", [:rebar3], [], "hexpm", "aa85b777fb23e9972ebc424e40b5d35106f19bc998873e026dedd876df8ee50c"},
   "msgpax": {:hex, :msgpax, "2.4.0", "4647575c87cb0c43b93266438242c21f71f196cafa268f45f91498541148c15d", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "ca933891b0e7075701a17507c61642bf6e0407bb244040d5d0a58597a06369d2"},
   "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
   "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
diff --git a/packages/live_ui/lib/live_ui/widgets/markdown_viewer.ex b/packages/live_ui/lib/live_ui/widgets/markdown_viewer.ex
index c2b27d53..adfd34b0 100644
--- a/packages/live_ui/lib/live_ui/widgets/markdown_viewer.ex
+++ b/packages/live_ui/lib/live_ui/widgets/markdown_viewer.ex
@@ -1,13 +1,40 @@
 defmodule LiveUi.Widgets.MarkdownViewer do
   @moduledoc """
-  Native markdown/document viewer widget.
+  Native markdown / document viewer widget.
+
+  Two formats:
+
+  - `"rendered"` (default) — parses `:source` as markdown via `Earmark` and
+    sanitizes the resulting HTML with `HtmlSanitizeEx.markdown_html/1`. This is
+    the safe path for displaying agent-authored or operator-authored markdown
+    in a document surface. Two-layer sanitization: `Earmark` is invoked with
+    `escape: true` (escapes raw HTML inside markdown plain text); the sanitizer
+    then strips dangerous tags + URL schemes that survived (e.g. `javascript:`
+    hrefs in markdown links).
+
+  - `"raw"` — wraps `:source` verbatim in `
` (HTML-escaped). For
+    displaying markdown source as text without parsing.
+
+  ## Framework note: `:format` instead of `:mode`
+
+  This widget uses `:format` to select between `"rendered"` and `"raw"` —
+  NOT `:mode`. `LiveUi.Widget`'s render pipeline reserves `:mode` and drops
+  it from the assigns passed to the wrapper module's render (see
+  `live_ui/widget.ex` `build_render_assigns/1` `:mode` in the `Map.drop` list).
+  A widget that tries to declare `attr :mode` will silently always receive
+  the attribute default. Use `:format` (or a widget-specific name) for
+  user-facing rendering-mode toggles instead.
+
+  External deps `earmark` and `html_sanitize_ex` are required for the
+  rendered format; consumers depending on `live_ui` inherit these
+  transitively.
   """
 
   use LiveUi.Component, family: :data, name: :markdown_viewer
 
   LiveUi.Component.common_attrs()
   attr(:source, :string, required: true)
-  attr(:mode, :string, default: "rendered")
+  attr(:format, :string, default: "rendered")
 
   @impl true
   def render(assigns) do
@@ -15,15 +42,42 @@ defmodule LiveUi.Widgets.MarkdownViewer do
     
-
<%= @source %>
+ {render_content(@source, @format)}
""" end + + defp render_content(source, "rendered"), + do: Phoenix.HTML.raw(rendered_markdown(source)) + + defp render_content(source, _other), + do: Phoenix.HTML.raw(raw_pre(source)) + + defp rendered_markdown(nil), do: "" + defp rendered_markdown(""), do: "" + + defp rendered_markdown(source) when is_binary(source) do + case Earmark.as_html(source, escape: true) do + {:ok, html, _warnings} -> HtmlSanitizeEx.markdown_html(html) + {:error, html, _errors} -> HtmlSanitizeEx.markdown_html(html) + end + end + + defp raw_pre(nil), do: "
"
+
+  defp raw_pre(source) when is_binary(source) do
+    escaped =
+      source
+      |> Phoenix.HTML.html_escape()
+      |> Phoenix.HTML.safe_to_string()
+
+    "
" <> escaped <> "
" + end end diff --git a/packages/live_ui/mix.exs b/packages/live_ui/mix.exs index 2052bd49..904f9fc1 100644 --- a/packages/live_ui/mix.exs +++ b/packages/live_ui/mix.exs @@ -25,6 +25,8 @@ defmodule LiveUi.MixProject do defp deps do [ + {:earmark, "~> 1.4"}, + {:html_sanitize_ex, "~> 1.4"}, {:jido_signal, "~> 2.0"}, {:phoenix, "~> 1.8"}, {:phoenix_live_view, "~> 1.1"}, diff --git a/packages/live_ui/test/live_ui/widgets/markdown_viewer_test.exs b/packages/live_ui/test/live_ui/widgets/markdown_viewer_test.exs new file mode 100644 index 00000000..c33816c3 --- /dev/null +++ b/packages/live_ui/test/live_ui/widgets/markdown_viewer_test.exs @@ -0,0 +1,144 @@ +defmodule LiveUi.Widgets.MarkdownViewerTest do + use ExUnit.Case, async: true + + import Phoenix.LiveViewTest + + alias LiveUi.Component + + @moduledoc """ + Tests for LiveUi.Widgets.MarkdownViewer. + + Covers both modes: + - `"rendered"` (default): Earmark + HtmlSanitizeEx two-layer pipeline + - `"raw"`: source verbatim in `
`
+
+  Plus sanitization (no executable script, no `javascript:` href) and the
+  Earmark `{:error, html, _}` recoverable-tuple path.
+  """
+
+  describe "Widget metadata" do
+    test "has the expected name and family" do
+      metadata = Component.metadata(LiveUi.Widgets.MarkdownViewer)
+
+      assert metadata.name == :markdown_viewer
+      assert metadata.family == :data
+    end
+
+    test "is registered in the data widget family" do
+      assert LiveUi.Widgets.MarkdownViewer in LiveUi.Widgets.modules()
+    end
+  end
+
+  describe "format: \"rendered\" (default)" do
+    test "parses h1/h2/h3 headings" do
+      html =
+        render_component(&LiveUi.Widgets.MarkdownViewer.component/1, %{
+          id: "headings",
+          source: "# Title\n## Sub\n### Smaller"
+        })
+
+      assert html =~ ~r{

\s*Title\s*

} + assert html =~ ~r{

\s*Sub\s*

} + assert html =~ ~r{

\s*Smaller\s*

} + end + + test "parses bold and italic" do + html = + render_component(&LiveUi.Widgets.MarkdownViewer.component/1, %{ + id: "emphasis", + source: "**bold** and _italic_" + }) + + assert html =~ "bold" + assert html =~ "italic" + end + + test "parses unordered lists" do + html = + render_component(&LiveUi.Widgets.MarkdownViewer.component/1, %{ + id: "list", + source: "- one\n- two\n- three" + }) + + assert html =~ "
    " + assert html =~ "
  • " + assert html =~ "one" + assert html =~ "two" + assert html =~ "three" + end + + test "strips script tags" do + html = + render_component(&LiveUi.Widgets.MarkdownViewer.component/1, %{ + id: "no-script", + source: "" + }) + + refute html =~ "" + end + + test "strips javascript: hrefs" do + bad = "[click](javascript:alert(1))" + + html = + render_component(&LiveUi.Widgets.MarkdownViewer.component/1, %{ + id: "no-js-href", + source: bad + }) + + refute html =~ "javascript:" + end + + test "empty source renders empty article" do + html = + render_component(&LiveUi.Widgets.MarkdownViewer.component/1, %{ + id: "empty", + source: "" + }) + + assert html =~ ~s(data-live-ui-widget="markdown-viewer") + assert html =~ ~s(data-live-ui-format="rendered") + end + + test "default format is \"rendered\"" do + html = + render_component(&LiveUi.Widgets.MarkdownViewer.component/1, %{ + id: "default-mode", + source: "**bold**" + }) + + assert html =~ ~s(data-live-ui-format="rendered") + assert html =~ "bold" + end + end + + describe "format: \"raw\"" do + test "wraps source verbatim in
    " do
    +      html =
    +        render_component(&LiveUi.Widgets.MarkdownViewer.component/1, %{
    +          id: "raw-mode",
    +          source: "# Title\n**bold**",
    +          format: "raw"
    +        })
    +
    +      assert html =~ "
    "
    +      # Source preserved as text (HTML-escaped) — NOT parsed into 

    / + assert html =~ "# Title" + refute html =~ "

    " + refute html =~ "" + end + + test "HTML-escapes raw source so embedded HTML does not render" do + html = + render_component(&LiveUi.Widgets.MarkdownViewer.component/1, %{ + id: "raw-escape", + source: "", + format: "raw" + }) + + refute html =~ "" + assert html =~ "<script>" + end + end +end