Type spec fixes: - duct_usable_* return boolean→float (delegate to duct_usable_for_band) - sanitize/1 spec includes :unicode error tuples - match_delete/1 broadened from :ets.match_spec() - telemetry_event local type replaces :telemetry.event/0 - wgrib2 parse_lon_val_segment corrected to tuple spec - preloaded Ecto assoc types in get_mission/get_contact! specs Unmatched returns: - _ = prefix on Task.start, Oban.insert, Repo.query!, PubSub.subscribe, :ets.new, and if-expression returns across 18 files Pattern match fixes: - markdown: restructure acc!=[] guard as direct pattern match - path_compute/pskr/skewt_location_resolver: remove dead clauses - calibrate.aprs_144: remove unreachable format_float catch-all - unused.ex: suppress MapSet.union no_opaque Also: remove unused unicode_util_compat from mix.lock
239 lines
7.6 KiB
Elixir
239 lines
7.6 KiB
Elixir
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
|
|
"<h#{level}>#{inline(text)}</h#{level}>"
|
|
end
|
|
|
|
defp render_block({:paragraph, lines}) do
|
|
"<p>#{Enum.map_join(lines, " ", &inline/1)}</p>"
|
|
end
|
|
|
|
defp render_block({:code, lang_attr, lines}) do
|
|
escaped = Enum.map_join(lines, "\n", &escape_html/1)
|
|
"<pre><code#{lang_attr}>#{escaped}</code></pre>"
|
|
end
|
|
|
|
defp render_block({:hr}) do
|
|
"<hr />"
|
|
end
|
|
|
|
defp render_block({:ul, items}) do
|
|
lis = Enum.map_join(items, "\n", fn item -> "<li>#{inline(item)}</li>" end)
|
|
"<ul>\n#{lis}\n</ul>"
|
|
end
|
|
|
|
defp render_block({:ol, items}) do
|
|
lis = Enum.map_join(items, "\n", fn item -> "<li>#{inline(item)}</li>" end)
|
|
"<ol>\n#{lis}\n</ol>"
|
|
end
|
|
|
|
defp render_block({:table, lines}) do
|
|
rows = Enum.map(lines, &parse_table_row/1)
|
|
|
|
case rows do
|
|
[header, _separator | body] ->
|
|
thead = "<thead><tr>#{cells(header, "th")}</tr></thead>"
|
|
tbody_rows = Enum.map_join(body, "\n", fn row -> "<tr>#{cells(row, "td")}</tr>" end)
|
|
"<table>\n#{thead}\n<tbody>\n#{tbody_rows}\n</tbody>\n</table>"
|
|
|
|
_ ->
|
|
tbody_rows = Enum.map_join(rows, "\n", fn row -> "<tr>#{cells(row, "td")}</tr>" end)
|
|
"<table>\n<tbody>\n#{tbody_rows}\n</tbody>\n</table>"
|
|
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)}</#{tag}>" 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, "<code>\\1</code>")
|
|
end
|
|
|
|
defp replace_bold(text) do
|
|
text
|
|
|> then(&Regex.replace(~r/\*\*(.+?)\*\*/, &1, "<strong>\\1</strong>"))
|
|
|> then(&Regex.replace(~r/__(.+?)__/, &1, "<strong>\\1</strong>"))
|
|
end
|
|
|
|
defp replace_italic(text) do
|
|
text
|
|
|> then(&Regex.replace(~r/\*(.+?)\*/, &1, "<em>\\1</em>"))
|
|
|> then(&Regex.replace(~r/_(.+?)_/, &1, "<em>\\1</em>"))
|
|
end
|
|
|
|
defp replace_links(text) do
|
|
Regex.replace(~r/\[([^\]]+)\]\(([^)]+)\)/, text, "<a href=\"\\2\">\\1</a>")
|
|
end
|
|
end
|