sloanelybutsurely.com/lib/web/markdown.ex
2025-04-12 13:04:57 -04:00

323 lines
8.7 KiB
Elixir

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