defmodule MDExNative.Comrak do @moduledoc ~S""" Markdown parsing and rendering powered by the Rust `comrak` crate. Elixir bindings for Comrak's parser and renderers. Options follow Rust [`comrak::Options`](https://docs.rs/comrak/latest/comrak/struct.Options.html) and use keyword lists. MDExNative also accepts `:sanitize` and `:syntax_highlight`. ## Examples iex> MDExNative.Comrak.markdown_to_html("# Hello") "

Hello

\n" iex> MDExNative.Comrak.markdown_to_html("- [x] done", extension: [tasklist: true]) "\n" iex> MDExNative.Comrak.anchorize("Hello World") "hello-world" """ @typedoc "Markdown source text." @type markdown :: String.t() @typedoc "Rendered HTML." @type html :: String.t() @typedoc "Rendered CommonMark XML." @type xml :: String.t() @typedoc "Parsed fenced code block info string." @type code_fence_info :: %{ language: String.t(), metadata: String.t(), attributes: %{String.t() => String.t() | true} } @typedoc "Parsed MDExNative.Comrak AST node." @type ast_node :: struct() @typedoc "Comrak [`Extension`](https://docs.rs/comrak/latest/comrak/options/struct.Extension.html) options." @type extension_options :: keyword() @typedoc "Comrak [`Parse`](https://docs.rs/comrak/latest/comrak/options/struct.Parse.html) options." @type parse_options :: keyword() @typedoc "Comrak [`Render`](https://docs.rs/comrak/latest/comrak/options/struct.Render.html) options." @type render_options :: keyword() @typedoc "Comrak [`Options`](https://docs.rs/comrak/latest/comrak/options/struct.Options.html), plus `:syntax_highlight` and `:sanitize`." @type options :: keyword() @doc ~S""" Parses Markdown into a generic MDExNative.Comrak AST. ## Examples iex> MDExNative.Comrak.parse_document("# Hello") %MDExNative.Comrak.Document{ nodes: [ %MDExNative.Comrak.Heading{ nodes: [ %MDExNative.Comrak.Text{literal: "Hello", sourcepos: %MDExNative.Comrak.Sourcepos{start: {1, 3}, end: {1, 7}}} ], level: 1, setext: false, sourcepos: %MDExNative.Comrak.Sourcepos{start: {1, 1}, end: {1, 7}} } ], sourcepos: %MDExNative.Comrak.Sourcepos{start: {1, 1}, end: {1, 7}} } """ @spec parse_document(markdown(), options()) :: MDExNative.Comrak.Document.t() def parse_document(markdown, options \\ []) when is_binary(markdown) do markdown |> MDExNative.Native.parse_document(options!(options)) |> check_native_output() end @doc ~S""" Converts Markdown to HTML. ## Options Pass Comrak options as keyword lists matching [`comrak::Options`](https://docs.rs/comrak/latest/comrak/struct.Options.html) or the extra `:syntax_highlight` and `:sanitize` options: MDExNative adds two top-level options: - `:extension` - maps to Comrak's [`Extension` options](https://docs.rs/comrak/latest/comrak/options/struct.Extension.html). - `:parse` - maps to Comrak's [`Parse` options](https://docs.rs/comrak/latest/comrak/options/struct.Parse.html). - `:render` - maps to Comrak's [`Render` options](https://docs.rs/comrak/latest/comrak/options/struct.Render.html), - `:syntax_highlight` - highlights fenced code blocks. Disabled by default. Defaults to `syntax_highlight: nil`. To highlight code, compile MDExNative with a highlighter and choose the engine: **Lumis** ``` config :mdex_native, syntax_highlighter: :lumis [engine: :lumis, opts: [formatter: {:html_inline, theme: "catppuccin_macchiato"}]] ``` **Syntect** ``` config :mdex_native, syntax_highlighter: :syntect [engine: :syntect, opts: [theme: "Catppuccin Macchiato"]] ``` See the [Syntax highlighting](syntax_highlighting.md) guide for complete examples. - `:sanitize` - cleans rendered HTML. Defaults to `nil`. See the [Sanitization](sanitization.md) guide for more info. ## Examples iex> MDExNative.Comrak.markdown_to_html("**bold**") "

bold

\n" iex> MDExNative.Comrak.markdown_to_html("- [x] done", extension: [tasklist: true]) "\n" iex> MDExNative.Comrak.markdown_to_html("

Title

Content

", render: [unsafe: true], sanitize: [rm_tags: ["h1"]]) "Title

Content

\n" # default disabled syntax highlighter iex> markdown = "```rust\nfn main() {}\n```" iex> MDExNative.Comrak.markdown_to_html(markdown) "
fn main() {}\n
\n" """ @spec markdown_to_html(markdown(), options()) :: html() def markdown_to_html(markdown, options \\ []) when is_binary(markdown) do markdown |> MDExNative.Native.markdown_to_html_with_options(options!(options)) |> check_native_output() end @doc ~S""" Converts a generic MDExNative.Comrak document to HTML. """ @spec document_to_html(MDExNative.Comrak.Document.t(), options()) :: html() def document_to_html(%MDExNative.Comrak.Document{} = document, options \\ []) do document |> MDExNative.Native.document_to_html_with_options(options!(options)) |> check_native_output() end @doc ~S""" Converts Markdown to XML. ## Examples iex> MDExNative.Comrak.markdown_to_xml("# Hello") "\n\n\n \n Hello\n \n\n" iex> MDExNative.Comrak.markdown_to_xml("# Hello", render: [sourcepos: true]) "\n\n\n \n Hello\n \n\n" """ @spec markdown_to_xml(markdown(), options()) :: xml() def markdown_to_xml(markdown, options \\ []) when is_binary(markdown) do markdown |> MDExNative.Native.markdown_to_xml_with_options(options!(options)) |> check_native_output() end @doc ~S""" Converts a generic MDExNative.Comrak document to XML. """ @spec document_to_xml(MDExNative.Comrak.Document.t(), options()) :: xml() def document_to_xml(%MDExNative.Comrak.Document{} = document, options \\ []) do document |> MDExNative.Native.document_to_xml_with_options(options!(options)) |> check_native_output() end @doc ~S""" Converts a generic MDExNative.Comrak document to CommonMark. """ @spec document_to_commonmark(MDExNative.Comrak.Document.t(), options()) :: markdown() def document_to_commonmark(%MDExNative.Comrak.Document{} = document, options \\ []) do document |> MDExNative.Native.document_to_commonmark_with_options(options!(options)) |> check_native_output() end @doc ~S""" Converts text to a heading anchor. ## Examples iex> MDExNative.Comrak.anchorize("Hello World") "hello-world" """ @spec anchorize(String.t()) :: String.t() def anchorize(text) when is_binary(text) do MDExNative.Native.text_to_anchor(text) end @doc ~S""" Returns whether a URL is considered dangerous or not. Calls [`comrak::html::dangerous_url/1`](https://docs.rs/comrak/latest/comrak/html/fn.dangerous_url.html). ## Examples iex> MDExNative.Comrak.dangerous_url?("javascript:alert(1)") true iex> MDExNative.Comrak.dangerous_url?("https://elixir-lang.org") false iex> MDExNative.Comrak.dangerous_url?("data:image/png;base64,AAAA") false """ @spec dangerous_url?(String.t()) :: boolean() def dangerous_url?(url) when is_binary(url) do MDExNative.Native.dangerous_url(url) end @doc ~S""" Parses a fenced code block info string into generic parts. The first word is returned as the language, the remaining text is preserved as metadata, and shell-like tokens in the metadata are exposed as attributes. ## Examples iex> MDExNative.Comrak.parse_code_fence_info(~s(elixir pre_class="demo" highlight_lines=2 include_highlights)) %{ language: "elixir", metadata: ~s(pre_class="demo" highlight_lines=2 include_highlights), attributes: %{ "pre_class" => "demo", "highlight_lines" => "2", "include_highlights" => true } } iex> MDExNative.Comrak.parse_code_fence_info("") %{language: "", metadata: "", attributes: %{}} """ @spec parse_code_fence_info(String.t() | nil) :: code_fence_info() def parse_code_fence_info(info) when is_binary(info) or is_nil(info) do {language, metadata} = split_code_fence_info(info || "") %{ language: language, metadata: metadata, attributes: code_fence_attributes(metadata) } end defp split_code_fence_info(info) do case String.split(info, ~r/\s+/, parts: 2, trim: true) do [language, metadata] -> {language, metadata} [language] -> {language, ""} [] -> {"", ""} end end defp code_fence_attributes(metadata) do metadata |> OptionParser.split() |> Map.new(fn token -> case String.split(token, "=", parts: 2) do [key, value] -> {key, value} [key] -> {key, true} end end) end defp options!(options) do Map.new(options, fn {key, value} when key in [:extension, :parse, :render] and is_list(value) -> {key, Map.new(value)} {:syntax_highlight, value} when is_list(value) -> {:syntax_highlight, syntax_highlight_options(value)} {:sanitize, value} -> {:sanitize, MDExNative.Sanitize.normalize(value)} {key, value} -> {key, value} end) end defp syntax_highlight_options(options) do options |> Map.new(fn {:opts, opts} when is_list(opts) -> {:opts, Map.new(opts, &syntax_highlight_option/1)} option -> syntax_highlight_option(option) end) end defp syntax_highlight_option({:formatter, {formatter, opts}}) when is_list(opts) do {:formatter, {formatter, Map.new(opts)}} end defp syntax_highlight_option(option), do: option defp check_native_output(:lumis_not_enabled) do raise """ Lumis is not enabled. Comrak tried to syntax highlight a code block with Lumis, but this NIF was not compiled with Lumis support. Enable it in your config: config :mdex_native, syntax_highlighter: :lumis """ end defp check_native_output(:syntect_not_enabled) do raise """ Syntect is not enabled. Comrak tried to syntax highlight a code block with Syntect, but this NIF was not compiled with Syntect support. Enable it in your config: config :mdex_native, syntax_highlighter: :syntect """ end defp check_native_output(value), do: value end