323 lines
8.7 KiB
Elixir
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
|