Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand All @@ -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"},
Expand All @@ -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"},
Expand Down
62 changes: 58 additions & 4 deletions packages/live_ui/lib/live_ui/widgets/markdown_viewer.ex
Original file line number Diff line number Diff line change
@@ -1,29 +1,83 @@
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 `<pre>` (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
~H"""
<article
id={@id}
data-live-ui-widget="markdown-viewer"
data-live-ui-mode={@mode}
data-live-ui-format={@format}
data-live-ui-tone={@tone}
data-live-ui-variant={@variant}
data-live-ui-state={@state}
class={@class}
{@rest}
>
<pre><%= @source %></pre>
{render_content(@source, @format)}
</article>
"""
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: "<pre></pre>"

defp raw_pre(source) when is_binary(source) do
escaped =
source
|> Phoenix.HTML.html_escape()
|> Phoenix.HTML.safe_to_string()

"<pre>" <> escaped <> "</pre>"
end
end
2 changes: 2 additions & 0 deletions packages/live_ui/mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
144 changes: 144 additions & 0 deletions packages/live_ui/test/live_ui/widgets/markdown_viewer_test.exs
Original file line number Diff line number Diff line change
@@ -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 `<pre>`

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{<h1>\s*Title\s*</h1>}
assert html =~ ~r{<h2>\s*Sub\s*</h2>}
assert html =~ ~r{<h3>\s*Smaller\s*</h3>}
end

test "parses bold and italic" do
html =
render_component(&LiveUi.Widgets.MarkdownViewer.component/1, %{
id: "emphasis",
source: "**bold** and _italic_"
})

assert html =~ "<strong>bold</strong>"
assert html =~ "<em>italic</em>"
end

test "parses unordered lists" do
html =
render_component(&LiveUi.Widgets.MarkdownViewer.component/1, %{
id: "list",
source: "- one\n- two\n- three"
})

assert html =~ "<ul>"
assert html =~ "<li>"
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: "<script>alert(1)</script>"
})

refute html =~ "<script>"
refute html =~ "</script>"
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 =~ "<strong>bold</strong>"
end
end

describe "format: \"raw\"" do
test "wraps source verbatim in <pre>" do
html =
render_component(&LiveUi.Widgets.MarkdownViewer.component/1, %{
id: "raw-mode",
source: "# Title\n**bold**",
format: "raw"
})

assert html =~ "<pre>"
# Source preserved as text (HTML-escaped) — NOT parsed into <h1> / <strong>
assert html =~ "# Title"
refute html =~ "<h1>"
refute html =~ "<strong>"
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: "<script>alert(1)</script>",
format: "raw"
})

refute html =~ "<script>alert(1)</script>"
assert html =~ "&lt;script&gt;"
end
end
end
Loading