remove custom markdown document -> html, tailwind prose
This commit is contained in:
parent
123f45c18d
commit
d403675d9a
3 changed files with 14 additions and 317 deletions
lib/web
|
@ -167,7 +167,7 @@ defmodule Web.CoreComponents do
|
||||||
|
|
||||||
def markdown(assigns) do
|
def markdown(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<div class={["markdown-content", @class]}>
|
<div class={["prose", @class]}>
|
||||||
{Phoenix.HTML.raw(Web.Markdown.to_html(@content))}
|
{Phoenix.HTML.raw(Web.Markdown.to_html(@content))}
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -16,8 +16,8 @@
|
||||||
navigate={Web.Paths.public_blog_path(blog)}
|
navigate={Web.Paths.public_blog_path(blog)}
|
||||||
class="flex justify-between items-center hover:bg-gray-50 -mx-2 px-2 py-1 rounded"
|
class="flex justify-between items-center hover:bg-gray-50 -mx-2 px-2 py-1 rounded"
|
||||||
>
|
>
|
||||||
<h3 class="p-name u-url">{blog.title}</h3>
|
<h3 class="p-name u-url truncate">{blog.title}</h3>
|
||||||
<div class="text-gray-500 dt-published">
|
<div class="text-gray-500 dt-published flex-shrink-0">
|
||||||
<.timex value={Core.Posts.publish_date_time(blog)} format="{Mfull} {D}, {YYYY}" />
|
<.timex value={Core.Posts.publish_date_time(blog)} format="{Mfull} {D}, {YYYY}" />
|
||||||
</div>
|
</div>
|
||||||
</.link>
|
</.link>
|
||||||
|
|
|
@ -1,323 +1,20 @@
|
||||||
defmodule Web.Markdown do
|
defmodule Web.Markdown do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Converts markdown to HTML using MDEx, with custom rendering to add Tailwind CSS classes.
|
Converts markdown to HTML using MDEx.
|
||||||
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
|
def to_html(markdown) when is_binary(markdown) do
|
||||||
# First parse the document to get the AST
|
MDEx.to_html!(markdown,
|
||||||
{:ok, document} =
|
extension: [
|
||||||
MDEx.parse_document(markdown,
|
strikethrough: true,
|
||||||
extension: [
|
table: true,
|
||||||
strikethrough: true,
|
autolink: true,
|
||||||
table: true,
|
tasklist: true,
|
||||||
autolink: true,
|
footnotes: true
|
||||||
tasklist: true,
|
],
|
||||||
footnotes: true
|
features: [syntax_highlight_theme: "catppuccin_latte"]
|
||||||
],
|
)
|
||||||
features: [syntax_highlight_theme: "catppuccin_latte"]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Render the document to HTML with Tailwind classes
|
|
||||||
render_document(document)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_html(nil), do: ""
|
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
|
end
|
||||||
|
|
Loading…
Reference in a new issue