defmodule Web.Markdown do @moduledoc """ Converts markdown to HTML using MDEx, with custom rendering to add Tailwind CSS classes. Uses iodata for efficient string generation. """ alias MDEx.Document @doc """ Converts markdown to HTML with syntax highlighting, additional extensions, and appropriate Tailwind CSS classes for styling. """ def to_html(markdown) when is_binary(markdown) do # First parse the document to get the AST {:ok, document} = MDEx.parse_document(markdown, extension: [ strikethrough: true, table: true, autolink: true, tasklist: true, footnotes: true ], features: [syntax_highlight_theme: "catppuccin_latte"] ) # Render the document to HTML with Tailwind classes render_document(document) end def to_html(nil), do: "" # Core rendering function for the document defp render_document(%Document{} = document) do document.nodes |> Enum.map(&render_node(&1, nil)) end # Heading (h1-h6) with anchor links defp render_node(%MDEx.Heading{level: level} = node, _parent) do tag = "h#{level}" class = case level do 1 -> "text-3xl font-bold mt-6 mb-4 group" 2 -> "text-2xl font-bold mt-5 mb-3 group" 3 -> "text-xl font-bold mt-4 mb-2 group" 4 -> "text-lg font-bold mt-3 mb-2 group" 5 -> "text-base font-bold mt-3 mb-2 group" 6 -> "text-sm font-bold mt-3 mb-2 group" end # Generate a slug from the text content for the ID content = Enum.map(node.nodes, &render_node(&1, node)) content_text = extract_text_content(node) slug = Slug.slugify(content_text) [ "<", tag, " id=\"", slug, "\" class=\"", class, " relative\">", "<a href=\"#", slug, "\" class=\"absolute -left-5 text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity\">¶</a>", content, "</", tag, ">" ] end # Paragraph defp render_node(%MDEx.Paragraph{} = node, _parent) do content = Enum.map(node.nodes, &render_node(&1, node)) ["<p>", content, "</p>"] end # Text defp render_node(%MDEx.Text{literal: literal}, _parent) do literal end # Strong (bold) defp render_node(%MDEx.Strong{} = node, _parent) do content = Enum.map(node.nodes, &render_node(&1, node)) ["<strong>", content, "</strong>"] end # Emphasis (italic) defp render_node(%MDEx.Emph{} = node, _parent) do content = Enum.map(node.nodes, &render_node(&1, node)) ["<em>", content, "</em>"] end # Strikethrough defp render_node(%MDEx.Strikethrough{} = node, _parent) do content = Enum.map(node.nodes, &render_node(&1, node)) ["<del>", content, "</del>"] end # Link defp render_node(%MDEx.Link{url: url, title: title} = node, _parent) do content = Enum.map(node.nodes, &render_node(&1, node)) title_attr = if title, do: [" title=\"", title, "\""], else: [] [ "<a href=\"", url, "\"", title_attr, " class=\"text-blue-600 hover:text-blue-800 hover:underline\">", content, "</a>" ] end # Image defp render_node(%MDEx.Image{url: url, title: title} = node, _parent) do alt = Enum.map(node.nodes, &render_node(&1, node)) title_attr = if title, do: [" title=\"", title, "\""], else: [] [ "<img src=\"", url, "\" alt=\"", alt, "\"", title_attr, " class=\"max-w-full my-4 rounded\">" ] end # Code (inline) defp render_node(%MDEx.Code{literal: literal}, _parent) do ["<code class=\"bg-gray-100 rounded px-1 py-0.5 text-sm\">", literal, "</code>"] end # Code blocks - use MDEx's built-in HTML rendering to preserve syntax highlighting defp render_node(%MDEx.CodeBlock{} = node, _parent) do # Use MDEx's built-in HTML rendering for code blocks to preserve syntax highlighting {:ok, html} = MDEx.to_html( %MDEx.Document{nodes: [node]}, extension: [], features: [syntax_highlight_theme: "catppuccin_latte"] ) # Return the HTML as is since it's already properly formatted with syntax highlighting String.replace(html, ~s|class="autumn-hl"|, ~s|class="autumn-hl p-2 rounded"|) end # Lists - Bullet defp render_node(%MDEx.List{list_type: :bullet} = node, _parent) do content = Enum.map(node.nodes, &render_node(&1, node)) ["<ul class=\"list-disc pl-6 my-4\">", content, "</ul>"] end # Lists - Ordered defp render_node(%MDEx.List{list_type: :ordered} = node, _parent) do content = Enum.map(node.nodes, &render_node(&1, node)) ["<ol class=\"list-decimal pl-6 my-4\">", content, "</ol>"] end # List items defp render_node(%MDEx.ListItem{} = node, _parent) do content = Enum.map(node.nodes, &render_node(&1, node)) ["<li class=\"mb-1\">", content, "</li>"] end # Task items (checkboxes) defp render_node(%MDEx.TaskItem{checked: checked} = node, _parent) do checkbox = if checked do "<input type=\"checkbox\" checked disabled class=\"rounded mr-1.5 text-blue-600\">" else "<input type=\"checkbox\" disabled class=\"rounded mr-1.5 text-blue-600\">" end content = Enum.map(node.nodes, &render_node(&1, node)) [ "<li class=\"mb-1 flex items-start\">", checkbox, "<div class=\"inline\">", content, "</div>", "</li>" ] end # Blockquote defp render_node(%MDEx.BlockQuote{} = node, _parent) do content = Enum.map(node.nodes, &render_node(&1, node)) [ "<blockquote class=\"border-l-4 border-gray-300 pl-4 py-1 my-4 italic\">", content, "</blockquote>" ] end # Horizontal Rule defp render_node(%MDEx.ThematicBreak{}, _parent) do "<hr class=\"border-t border-gray-300 my-6\">" end # Table defp render_node(%MDEx.Table{} = node, _parent) do content = Enum.map(node.nodes, &render_node(&1, node)) [ "<div class=\"overflow-x-auto my-4\"><table class=\"min-w-full divide-y divide-gray-200\">", content, "</table></div>" ] end # Table row defp render_node(%MDEx.TableRow{header: true} = node, _parent) do content = Enum.map(node.nodes, &render_node(&1, node)) ["<tr class=\"bg-gray-50 border-b border-gray-200\">", content, "</tr>"] end defp render_node(%MDEx.TableRow{header: false} = node, _parent) do content = Enum.map(node.nodes, &render_node(&1, node)) ["<tr class=\"border-b border-gray-200\">", content, "</tr>"] end # Table cell in header row defp render_node(%MDEx.TableCell{} = node, %MDEx.TableRow{header: true}) do content = Enum.map(node.nodes, &render_node(&1, node)) [ "<th class=\"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider\">", content, "</th>" ] end # Table cell in normal row defp render_node(%MDEx.TableCell{} = node, _parent) do content = Enum.map(node.nodes, &render_node(&1, node)) ["<td class=\"px-6 py-4 whitespace-nowrap text-sm\">", content, "</td>"] end # HTML blocks (pass through) defp render_node(%MDEx.HtmlBlock{literal: literal}, _parent) do literal end # HTML inline (pass through) defp render_node(%MDEx.HtmlInline{literal: literal}, _parent) do literal end # Soft break (line break that doesn't create new paragraph) defp render_node(%MDEx.SoftBreak{}, _parent) do "\n" end # Hard break (explicit line break with <br>) defp render_node(%MDEx.LineBreak{}, _parent) do "<br>" end # Footnote reference defp render_node(%MDEx.FootnoteReference{name: name}, _parent) do [ "<sup class=\"footnote-ref\">", "<a href=\"#fn-", name, "\" id=\"fnref-", name, "\" class=\"text-blue-600 hover:text-blue-800\">", name, "</a>", "</sup>" ] end # Footnote definition defp render_node(%MDEx.FootnoteDefinition{name: name} = node, _parent) do content = Enum.map(node.nodes, &render_node(&1, node)) [ "<div class=\"footnote flex flex-row\" id=\"fn-", name, "\">", "<p class=\"my-1 flex\">", "<span class=\"mr-2 flex-shrink-0\"><sup>", name, ".</sup></span>", "<span class=\"inline\">", content, " <a href=\"#fnref-", name, "\" class=\"text-blue-600 hover:text-blue-800 text-sm\">↩</a>", "</span>", "</p>", "</div>" ] end # Extract plain text from a node tree (for generating slugs) defp extract_text_content(node) do cond do is_map(node) && Map.has_key?(node, :literal) && is_binary(node.literal) -> node.literal is_map(node) && Map.has_key?(node, :nodes) -> node.nodes |> Enum.map(&extract_text_content/1) |> Enum.join("") true -> "" end end end