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 "#{inline(text)}" end defp render_block({:paragraph, lines}) 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 "
" end defp render_block({:ul, items}) do lis = Enum.map_join(items, "\n", fn item -> "
  • #{inline(item)}
  • " end) "" end defp render_block({:ol, items}) do lis = Enum.map_join(items, "\n", fn item -> "
  • #{inline(item)}
  • " end) "
      \n#{lis}\n
    " end defp render_block({:table, lines}) do rows = Enum.map(lines, &parse_table_row/1) case rows do [header, _separator | body] -> thead = "#{cells(header, "th")}" tbody_rows = Enum.map_join(body, "\n", fn row -> "#{cells(row, "td")}" end) "\n#{thead}\n\n#{tbody_rows}\n\n
    " _ -> tbody_rows = Enum.map_join(rows, "\n", fn row -> "#{cells(row, "td")}" end) "\n\n#{tbody_rows}\n\n
    " end end defp parse_table_row(line) do line |> String.trim() |> String.trim("|") |> String.split("|") |> Enum.map(&String.trim/1) end defp cells(row, tag) do Enum.map_join(row, "", fn cell -> "<#{tag}>#{inline(cell)}" end) end # ── Inline formatting ──────────────────────────────────────────── defp inline(text) do text |> escape_html() |> replace_inline_code() |> replace_bold() |> replace_italic() |> replace_links() end defp escape_html(text) do text |> String.replace("&", "&") |> String.replace("<", "<") |> String.replace(">", ">") |> String.replace("\"", """) end defp replace_inline_code(text) do Regex.replace(~r/`([^`]+)`/, text, "\\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