From aadbacb1c60b0fc9d042facc66f3fda28c88f2de Mon Sep 17 00:00:00 2001 From: Jon Klein Date: Fri, 4 Oct 2024 15:09:11 -0400 Subject: [PATCH] Add Elixir functions to expose native to_commonmark & to_commonmark_with_options (#70) --- lib/mdex.ex | 65 ++++++++++++++++++++++++++++++- lib/mdex/native.ex | 2 + native/comrak_nif/src/lib.rs | 75 +++++++++++++++++++++++++++++++++++- test/format_test.exs | 25 ++++++++++++ 4 files changed, 164 insertions(+), 3 deletions(-) diff --git a/lib/mdex.ex b/lib/mdex.ex index 9efbf82..eda7c36 100644 --- a/lib/mdex.ex +++ b/lib/mdex.ex @@ -167,6 +167,63 @@ defmodule MDEx do end end + @doc """ + Convert an AST to Commonmark using default options. + + To customize the output, use `to_commonmark/2`. + + ## Examples + + iex> MDEx.to_commonmark(MDEx.parse_document!("# MDEx")) + {:ok, "# MDEx\\n"} + """ + + @spec to_commonmark(ast :: md_ast()) :: {:ok, String.t()} | {:error, MDEx.DecodeError.t()} + def to_commonmark(ast) do + Native.ast_to_commonmark(ast) + end + + @doc """ + Same as `to_commonmark/1` but raises `MDEx.DecodeError` if the conversion fails. + """ + @spec to_commonmark!(ast :: md_ast()) :: String.t() + def to_commonmark!(ast) do + case to_commonmark(ast) do + {:ok, md} -> md + {:error, error} -> raise error + end + end + + @doc """ + Convert an AST to Commonmark with custom options. + + ## Options + + See the [Options](#module-options) section for the available options. + + ## Examples + + iex> MDEx.to_commonmark(MDEx.parse_document!("# MDEx"), []) + {:ok, "# MDEx\\n"} + + """ + def to_commonmark(md_or_ast, opts) when is_list(md_or_ast) do + md_or_ast + |> maybe_wrap_document() + |> Native.ast_to_commonmark_with_options(comrak_options(opts)) + end + + @doc """ + Same as `to_commonmark/2` but raises `MDEx.DecodeError` if the conversion fails. + """ + @spec to_commonmark!(ast :: md_ast(), keyword()) :: String.t() + def to_commonmark!(ast, opts) do + case to_commonmark(ast, opts) do + {:ok, md} -> md + {:error, error} -> raise error + end + end + defp comrak_options(opts) do extension = Keyword.get(opts, :extension, %{}) parse = Keyword.get(opts, :parse, %{}) @@ -204,8 +261,12 @@ defmodule MDEx do defp maybe_wrap_error({:ok, result}), do: {:ok, result} defp maybe_wrap_error({:error, {reason, found}}), do: {:error, %MDEx.DecodeError{reason: reason, found: found}} - defp maybe_wrap_error({:error, {reason, found, node}}), do: {:error, %MDEx.DecodeError{reason: reason, found: found, node: node}} - defp maybe_wrap_error({:error, {reason, found, node, kind}}), do: {:error, %MDEx.DecodeError{reason: reason, found: found, node: node, kind: kind}} + + defp maybe_wrap_error({:error, {reason, found, node}}), + do: {:error, %MDEx.DecodeError{reason: reason, found: found, node: node}} + + defp maybe_wrap_error({:error, {reason, found, node, kind}}), + do: {:error, %MDEx.DecodeError{reason: reason, found: found, node: node, kind: kind}} defp maybe_wrap_error({:error, {reason, found, node, attr, kind}}), do: {:error, %MDEx.DecodeError{reason: reason, found: found, node: node, attr: attr, kind: kind}} diff --git a/lib/mdex/native.ex b/lib/mdex/native.ex index 7b05e09..3687f93 100644 --- a/lib/mdex/native.ex +++ b/lib/mdex/native.ex @@ -58,4 +58,6 @@ defmodule MDEx.Native do def parse_document(_md, _options), do: :erlang.nif_error(:nif_not_loaded) def ast_to_html(_ast), do: :erlang.nif_error(:nif_not_loaded) def ast_to_html_with_options(_ast, _options), do: :erlang.nif_error(:nif_not_loaded) + def ast_to_commonmark(_ast), do: :erlang.nif_error(:nif_not_loaded) + def ast_to_commonmark_with_options(_ast, _options), do: :erlang.nif_error(:nif_not_loaded) end diff --git a/native/comrak_nif/src/lib.rs b/native/comrak_nif/src/lib.rs index 7f88ac9..858887f 100644 --- a/native/comrak_nif/src/lib.rs +++ b/native/comrak_nif/src/lib.rs @@ -24,7 +24,9 @@ rustler::init!( markdown_to_html, markdown_to_html_with_options, ast_to_html, - ast_to_html_with_options + ast_to_html_with_options, + ast_to_commonmark, + ast_to_commonmark_with_options ] ); @@ -157,3 +159,74 @@ fn ast_to_html_with_options<'a>( } } } + +#[rustler::nif(schedule = "DirtyCpu")] +fn ast_to_commonmark<'a>(env: Env<'a>, ast: Term<'a>) -> NifResult> { + let ex_node = types::nodes::ExNode::decode(ast)?; + + let arena = Arena::new(); + let comrak_ast = ex_node_to_comrak_ast(&arena, &ex_node); + + // FIXME: error handling format_html and from_utf8 + + let inkjet_adapter = InkjetAdapter::default(); + let mut plugins = ComrakPlugins::default(); + plugins.render.codefence_syntax_highlighter = Some(&inkjet_adapter); + + let mut buffer = vec![]; + comrak::format_commonmark_with_plugins(comrak_ast, &Options::default(), &mut buffer, &plugins) + .unwrap(); + let html = String::from_utf8(buffer).unwrap(); + + Ok((ok(), html).encode(env)) +} + +#[rustler::nif(schedule = "DirtyCpu")] +fn ast_to_commonmark_with_options<'a>( + env: Env<'a>, + ast: Term<'a>, + options: ExOptions, +) -> NifResult> { + let ex_node = types::nodes::ExNode::decode(ast)?; + let arena = Arena::new(); + let comrak_ast = ex_node_to_comrak_ast(&arena, &ex_node); + + let comrak_options = comrak::Options { + extension: extension_options_from_ex_options(&options), + parse: parse_options_from_ex_options(&options), + render: render_options_from_ex_options(&options), + }; + + match &options.features.syntax_highlight_theme { + Some(theme) => { + let inkjet_adapter = InkjetAdapter::new( + theme, + options + .features + .syntax_highlight_inline_style + .unwrap_or(true), + ); + let mut plugins = ComrakPlugins::default(); + plugins.render.codefence_syntax_highlighter = Some(&inkjet_adapter); + + let mut buffer = vec![]; + comrak::format_commonmark_with_plugins( + comrak_ast, + &comrak_options, + &mut buffer, + &plugins, + ) + .unwrap(); + let unsafe_html = String::from_utf8(buffer).unwrap(); + + maybe_sanitize(env, unsafe_html, options.features.sanitize) + } + None => { + let mut buffer = vec![]; + comrak::format_commonmark(comrak_ast, &comrak_options, &mut buffer).unwrap(); + let unsafe_html = String::from_utf8(buffer).unwrap(); + + maybe_sanitize(env, unsafe_html, options.features.sanitize) + } + } +} diff --git a/test/format_test.exs b/test/format_test.exs index d2ca769..0262b66 100644 --- a/test/format_test.exs +++ b/test/format_test.exs @@ -20,6 +20,18 @@ defmodule MDEx.FormatTest do greentext: true ] + def assert_commonmark(document, extension \\ []) do + opts = [ + extension: Keyword.merge(@extension, extension), + render: [unsafe_: true] + ] + + assert {:ok, ast} = MDEx.parse_document(document, opts) + assert {:ok, markdown} = MDEx.to_commonmark(ast, opts) + + assert markdown == document + end + def assert_format(document, expected, extension \\ []) do opts = [ extension: Keyword.merge(@extension, extension), @@ -28,12 +40,21 @@ defmodule MDEx.FormatTest do assert {:ok, ast} = MDEx.parse_document(document, opts) assert {:ok, html} = MDEx.to_html(ast, opts) + # IO.puts(html) assert html == expected end test "text" do assert_format("mdex", "

mdex

\n") + + assert_commonmark(""" + mdex + """) + + assert_commonmark(""" + Hello ~world~ there + """) end test "front matter" do @@ -54,6 +75,10 @@ defmodule MDEx.FormatTest do """, "
\n

MDEx

\n
\n" ) + + assert_commonmark(""" + > MDEx + """) end describe "list" do