<%= @source %>+ {render_content(@source, @format)}
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 =~ "" 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