defmodule Microwaveprop.Markdown do
@moduledoc """
Minimal Markdown-to-HTML converter. Handles the subset used by algo.md:
headings, paragraphs, fenced code blocks, tables, lists, inline formatting,
links, and horizontal rules. Derived from Earmark's approach but without
the parser combinator dependency.
"""
@doc "Converts a Markdown string to HTML."
@spec to_html!(String.t()) :: String.t()
def to_html!(markdown) do
markdown
|> String.replace("\r\n", "\n")
|> String.split("\n")
|> parse_blocks([])
|> Enum.map_join("\n", &render_block/1)
end
# ── Block parsing ────────────────────────────────────────────────
defp parse_blocks([], acc), do: Enum.reverse(acc)
# Fenced code block
defp parse_blocks(["```" <> lang | rest], acc) do
{code_lines, remaining} = take_until_fence(rest, [])
lang = String.trim(lang)
lang_attr = if lang == "", do: "", else: " class=\"language-#{lang}\""
parse_blocks(remaining, [{:code, lang_attr, code_lines} | acc])
end
# Horizontal rule
defp parse_blocks([line | rest], acc) when line in ["---", "***", "___"] do
parse_blocks(rest, [{:hr} | acc])
end
# Heading
defp parse_blocks(["#" <> _ = line | rest], acc) do
{level, text} = parse_heading(line)
parse_blocks(rest, [{:heading, level, text} | acc])
end
# Table (starts with |)
defp parse_blocks(["|" <> _ = line | rest], acc) do
{table_lines, remaining} = take_table([line | rest], [])
parse_blocks(remaining, [{:table, table_lines} | acc])
end
# Unordered list — accept any of CommonMark's bullet markers (- * +).
defp parse_blocks([line | rest], acc) when binary_part(line, 0, min(byte_size(line), 2)) in ["- ", "* ", "+ "] do
item = binary_part(line, 2, byte_size(line) - 2)
{items, remaining} = take_list(rest, [item])
parse_blocks(remaining, [{:ul, items} | acc])
end
# Ordered list
defp parse_blocks([line | rest], acc) do
if Regex.match?(~r/^\d+\.\s/, line) do
item = Regex.replace(~r/^\d+\.\s+/, line, "")
{items, remaining} = take_ordered_list(rest, [item])
parse_blocks(remaining, [{:ol, items} | acc])
else
case String.trim(line) do
"" ->
parse_blocks(rest, acc)
trimmed ->
{para_lines, remaining} = take_paragraph(rest, [trimmed])
parse_blocks(remaining, [{:paragraph, para_lines} | acc])
end
end
end
defp take_until_fence([], acc), do: {Enum.reverse(acc), []}
defp take_until_fence(["```" <> _ | rest], acc), do: {Enum.reverse(acc), rest}
defp take_until_fence([line | rest], acc), do: take_until_fence(rest, [line | acc])
defp parse_heading(line) do
{hashes, text} = split_heading(line, 0)
{min(hashes, 6), String.trim(text)}
end
defp split_heading("#" <> rest, n), do: split_heading(rest, n + 1)
defp split_heading(rest, n), do: {n, rest}
defp take_table(["|" <> _ = line | rest], acc), do: take_table(rest, [line | acc])
defp take_table(rest, acc), do: {Enum.reverse(acc), rest}
defp take_list([line | rest] = all, acc) do
cond do
bullet_line?(line) ->
item = binary_part(line, 2, byte_size(line) - 2)
take_list(rest, [item | acc])
String.starts_with?(line, " ") ->
# acc is never empty here — a continuation line always follows a bullet
[prev | items] = acc
take_list(rest, [prev <> " " <> String.trim(line) | items])
true ->
{Enum.reverse(acc), all}
end
end
defp take_list([], acc), do: {Enum.reverse(acc), []}
defp bullet_line?(line) when byte_size(line) >= 2 do
binary_part(line, 0, 2) in ["- ", "* ", "+ "]
end
defp bullet_line?(_), do: false
defp take_ordered_list([line | rest], acc) do
if Regex.match?(~r/^\d+\.\s/, line) do
item = Regex.replace(~r/^\d+\.\s+/, line, "")
take_ordered_list(rest, [item | acc])
else
{Enum.reverse(acc), [line | rest]}
end
end
defp take_ordered_list([], acc), do: {Enum.reverse(acc), []}
defp take_paragraph([], acc), do: {Enum.reverse(acc), []}
defp take_paragraph(["" | rest], acc), do: {Enum.reverse(acc), rest}
defp take_paragraph(["#" <> _ | _] = rest, acc), do: {Enum.reverse(acc), rest}
defp take_paragraph(["|" <> _ | _] = rest, acc), do: {Enum.reverse(acc), rest}
defp take_paragraph([line | _] = rest, acc) when binary_part(line, 0, min(byte_size(line), 2)) in ["- ", "* ", "+ "] do
{Enum.reverse(acc), rest}
end
defp take_paragraph(["```" <> _ | _] = rest, acc), do: {Enum.reverse(acc), rest}
defp take_paragraph(["---" | _] = rest, acc), do: {Enum.reverse(acc), rest}
defp take_paragraph([line | rest], acc) do
if Regex.match?(~r/^\d+\.\s/, line) do
{Enum.reverse(acc), [line | rest]}
else
take_paragraph(rest, [String.trim(line) | acc])
end
end
# ── Block rendering ──────────────────────────────────────────────
defp render_block({:heading, level, text}) do
"
#{Enum.map_join(lines, " ", &inline/1)}
" end defp render_block({:code, lang_attr, lines}) do escaped = Enum.map_join(lines, "\n", &escape_html/1) "#{escaped}"
end
defp render_block({:hr}) do
"\\1")
end
defp replace_bold(text) do
text
|> then(&Regex.replace(~r/\*\*(.+?)\*\*/, &1, "\\1"))
|> then(&Regex.replace(~r/__(.+?)__/, &1, "\\1"))
end
defp replace_italic(text) do
text
|> then(&Regex.replace(~r/\*(.+?)\*/, &1, "\\1"))
|> then(&Regex.replace(~r/_(.+?)_/, &1, "\\1"))
end
defp replace_links(text) do
Regex.replace(~r/\[([^\]]+)\]\(([^)]+)\)/, text, "\\1")
end
end