8.7 KiB · text 5af55d9
defmodule GitGud.Markdown do
@moduledoc """
Single entry point for rendering user-authored markdown into HTML.
Backed by MDEx (Comrak) with full GitHub-flavored markdown:
* tables, strikethrough, task lists, autolink
* GitHub-style alerts (`> [!NOTE]` blocks)
* footnotes, hardbreaks
* fenced code blocks with Lumis-powered syntax highlighting
* heading anchors (so deep links can target sections)
* **cross-references**: `#42` and `owner/repo#42` expand to links
when the caller passes `:repo` — number is resolved against the
repo's shared issue/PR numbering and routed to whichever entity
that number actually is.
Output is sanitized — MDEx's HTML output already escapes user content;
callers should still wrap with `Phoenix.HTML.raw/1` deliberately and
render inside `<div class="prose ...">` for typography.
Cached in `GitGud.Cache` keyed by content hash + theme + repo id (when
cross-refs are enabled — the same body in two different repos renders
to different HTML).
"""
alias GitGud.Cache
alias GitGud.Themes.Editor, as: EditorTheme
@ttl :timer.hours(24)
@max_bytes 500_000
@doc """
Render `body` to safe HTML. Options:
* `:theme` — Lumis theme used for fenced code blocks
* `:repo` — `%GitGud.Repositories.Repository{}` enabling
`#N` / `owner/name#N` cross-reference expansion
"""
def to_html(body, opts \\ [])
def to_html(nil, _opts), do: ""
def to_html("", _opts), do: ""
def to_html(body, opts) when is_binary(body) do
theme = opts |> Keyword.get(:theme) |> EditorTheme.resolve()
repo = Keyword.get(opts, :repo)
cond do
byte_size(body) > @max_bytes ->
body
|> Phoenix.HTML.html_escape()
|> Phoenix.HTML.safe_to_string()
|> wrap_pre()
true ->
cache_get_or_compute(body, theme, repo)
end
end
defp cache_get_or_compute(body, theme, repo) do
key = cache_key(body, theme, repo)
case Cache.get(key) do
nil ->
html = render(body, theme, repo)
Cache.put(key, html, ttl: @ttl)
html
cached ->
cached
end
end
defp render(body, theme, repo) do
expanded = if repo, do: expand_cross_refs(body, repo), else: body
if Code.ensure_loaded?(MDEx) do
try do
MDEx.to_html!(expanded, options(theme))
rescue
_ -> escape_fallback(body)
catch
_, _ -> escape_fallback(body)
end
else
escape_fallback(body)
end
end
# ── cross-references ────────────────────────────────────────────────
# Walk the text alternating between code regions (preserved as-is)
# and non-code (where we apply the `#N` / `owner/name#N` regex).
# `~`, single, and triple backticks are the only delimiters we care
# about for skipping; raw HTML / link text is left to the markdown
# parser to disambiguate.
defp expand_cross_refs(text, repo) do
text
|> split_code_regions()
|> Enum.map(fn
{:text, t} -> apply_refs(t, repo)
{:code, c} -> c
end)
|> IO.iodata_to_binary()
end
# Splits "abc `foo` def ```block``` end" into alternating segments.
defp split_code_regions(text), do: split_code_regions(text, :text, [], [])
defp split_code_regions("", mode, buf, acc) do
final = [{mode, buf |> Enum.reverse() |> IO.iodata_to_binary()} | acc] |> Enum.reverse()
Enum.reject(final, fn {_, s} -> s == "" end)
end
defp split_code_regions("```" <> rest, :text, buf, acc) do
split_code_regions(rest, :code, ["```"], [{:text, flush(buf)} | acc])
end
defp split_code_regions("```" <> rest, :code, buf, acc) do
split_code_regions(rest, :text, [], [{:code, flush(["```" | buf])} | acc])
end
defp split_code_regions("`" <> rest, :text, buf, acc) do
split_code_regions(rest, :code_span, ["`"], [{:text, flush(buf)} | acc])
end
defp split_code_regions("`" <> rest, :code_span, buf, acc) do
split_code_regions(rest, :text, [], [{:code, flush(["`" | buf])} | acc])
end
defp split_code_regions(<<ch::utf8, rest::binary>>, mode, buf, acc) do
split_code_regions(rest, mode, [<<ch::utf8>> | buf], acc)
end
defp flush(buf), do: buf |> Enum.reverse() |> IO.iodata_to_binary()
# The unified pattern matches either:
# * `#42` (current-repo decimal)
# * `#x2a` (current-repo hex — case-insensitive, no `0x`)
# * `owner/repo#42` (cross-repo decimal)
# * `owner/repo#x2a` (cross-repo hex)
#
# `(?<![\w/])` anchors so we don't match inside identifiers or URLs.
# `(?![\w])` keeps `#5x` from matching the `5`.
@ref_re ~r/(?<![\w\/])(?:(?<owner>[A-Za-z0-9_.-]+)\/(?<name>[A-Za-z0-9_.-]+))?#(?<ref>x[0-9a-fA-F]+|\d+)(?![\w])/
defp apply_refs(text, repo) do
Regex.replace(@ref_re, text, fn full, owner, name, ref_str ->
case parse_ref_number(ref_str) do
:error ->
full
number ->
cond do
owner != "" and name != "" ->
cross_repo_link(owner, name, ref_str, number, full)
true ->
local_link(repo, ref_str, number, full)
end
end
end)
end
defp local_link(repo, ref_str, number, fallback) do
case GitGud.Issues.lookup_referent(repo, number) do
{kind, item} ->
path = path_for(kind, repo, number)
markdown_link("#" <> ref_str, path, item.title)
nil ->
fallback
end
end
defp cross_repo_link(owner, name, ref_str, number, fallback) do
case GitGud.Repositories.find_by_path(owner, name) do
nil ->
fallback
repo ->
case GitGud.Issues.lookup_referent(repo, number) do
{kind, item} ->
path = path_for(kind, repo, number)
markdown_link("#{owner}/#{name}##{ref_str}", path, item.title)
nil ->
fallback
end
end
end
defp markdown_link(text, href, title) do
"[" <> text <> "](" <> href <> " \"" <> escape_attr(title) <> "\")"
end
@doc """
Format `body` as a markdown blockquote attributed to `author`. Each
line is prefixed with `> `; an empty line separates the author
attribution from the quoted content. Used by the quote-reply
buttons on issue / PR comments.
Idempotent on already-quoted text in the sense that quoting a quote
produces an explicit nested `>>` — markdown supports that natively.
"""
def quote_block(body, author \\ nil)
def quote_block(nil, _), do: ""
def quote_block("", _), do: ""
def quote_block(body, author) when is_binary(body) do
prefix_lines = body |> String.split(~r/\r?\n/) |> Enum.map(&("> " <> &1))
quoted = Enum.join(prefix_lines, "\n")
case author do
author when is_binary(author) and author != "" ->
"> **@" <> author <> " wrote:**\n>\n" <> quoted
_ ->
quoted
end
end
# `42` → 42; `x2a` → 42; `xnotahex` → :error.
defp parse_ref_number("x" <> hex) when byte_size(hex) > 0 do
case Integer.parse(hex, 16) do
{n, ""} -> n
_ -> :error
end
end
defp parse_ref_number(dec) do
case Integer.parse(dec, 10) do
{n, ""} -> n
_ -> :error
end
end
defp path_for(:issue, repo, n),
do: "/r/#{handle(repo)}/#{repo.name}/issues/#{n}"
defp path_for(:pull_request, repo, n),
do: "/r/#{handle(repo)}/#{repo.name}/pulls/#{n}"
defp handle(repo) do
repo = GitGud.Repo.preload(repo, [:owner, :organization])
GitGud.Repositories.Storage.repo_handle(repo)
end
defp escape_attr(s),
do: s |> to_string() |> String.replace("\"", "&quot;")
# ── MDEx wiring ─────────────────────────────────────────────────────
defp options(theme) do
[
extension: [
strikethrough: true,
table: true,
autolink: true,
tasklist: true,
footnotes: true,
alerts: true,
header_id_prefix: "h-"
],
parse: [
smart: true,
relaxed_tasklist_matching: true,
relaxed_autolinks: true
],
render: [
hardbreaks: false,
unsafe: false,
github_pre_lang: true,
full_info_string: true
],
syntax_highlight: [
formatter: {:html_inline, theme: theme}
]
]
end
defp escape_fallback(body) do
body
|> Phoenix.HTML.html_escape()
|> Phoenix.HTML.safe_to_string()
|> wrap_pre()
end
defp wrap_pre(html), do: ~s(<pre class="markdown-fallback">) <> html <> "</pre>"
defp cache_key(body, theme, repo) do
repo_id = if repo, do: repo.id, else: nil
{:markdown, :crypto.hash(:sha256, body) |> Base.encode16(case: :lower), theme, repo_id}
end
end