Packages

A layered agent framework for Elixir: structured output, tool-calling, streaming, stateful supervised agents, multi-agent sessions, durable persistence, compaction/cost/permissions and MCP — powered by the BEAM.

Current section

Files

Jump to
exagent docs markdown_formatter.exs
Raw

docs/markdown_formatter.exs

defmodule ExAgent.Docs.Markdown do
  @moduledoc false

  # ExDoc's public custom-formatter callback. Keep stock generation, then rebase
  # source-relative links for flattened Markdown. No changes to ExDoc, runtime
  # code, source snippets or the HTML/EPUB formatters.
  def run(config, modules, extras) do
    config = %{
      config
      | description:
          "Unreleased ExAgent v2 candidate (nominal version #{config.version}); published 1.x contracts differ. " <>
            (config.description || "")
    }

    result = apply(ExDoc.Formatter.MARKDOWN, :run, [config, modules, extras])
    sources = Map.new(extras, &{Path.expand(&1.source_path), &1.id <> ".md"})
    pages = MapSet.new(Enum.map(extras ++ modules, &(&1.id <> ".html")))

    for extra <- extras do
      path = Path.join(config.output, extra.id <> ".md")
      File.write!(path, rebase(extra.source_doc, extra.source_path, sources, pages))
    end

    # Module documentation can reference extras by repository path (including
    # ExAgent's README-derived moduledoc). Rebase those stock Markdown files too.
    for module <- modules do
      path = Path.join(config.output, module.id <> ".md")

      File.write!(
        path,
        rebase(File.read!(path), module.source_path || "README.md", sources, pages)
      )
    end

    result
  end

  # Tooling regression seam; this module never enters lib/.
  def rebase(markdown, source_path, sources, pages) do
    {lines, _fence} =
      markdown
      |> String.split("\n")
      |> Enum.map_reduce(nil, fn line, fence ->
        marker = Regex.run(~r/^\s{0,3}(`{3,}|~{3,})/, line)

        cond do
          marker && is_nil(fence) ->
            [_, opening] = marker
            {line, opening}

          marker && fence && closes?(line, fence) ->
            {line, nil}

          fence ->
            {line, fence}

          true ->
            {rebase_line(line, source_path, sources, pages), nil}
        end
      end)

    Enum.join(lines, "\n")
  end

  defp closes?(line, fence) do
    marker = String.first(fence)
    pattern = "^\\s{0,3}" <> marker <> "{" <> to_string(String.length(fence)) <> ",}\\s*$"
    Regex.match?(Regex.compile!(pattern), line)
  end

  defp rebase_line(line, source, sources, pages) do
    if Regex.match?(~r/^\[[^\]]+\]:\s*/, line) do
      rebase_text(line, source, sources, pages)
    else
      ~r/(`+[^`]*`+)/
      |> Regex.split(line, include_captures: true)
      |> Enum.map_join(fn part ->
        if String.starts_with?(part, "`"),
          do: part,
          else: rebase_text(part, source, sources, pages)
      end)
    end
  end

  defp rebase_text(text, source, sources, pages) do
    rewrite = &destination(&1, source, sources, pages)

    text =
      Regex.replace(~r/(\]\()([^\s)]+)(?=[\s)])/, text, fn _, prefix, path ->
        prefix <> rewrite.(path)
      end)

    text =
      Regex.replace(~r/^(\[[^\]]+\]:\s*)(\S+)/, text, fn _, prefix, path ->
        prefix <> rewrite.(path)
      end)

    Regex.replace(~r/(href=")([^"]+)(")/, text, fn _, prefix, path, suffix ->
      prefix <> rewrite.(path) <> suffix
    end)
  end

  defp destination(path, source, sources, pages) do
    uri = URI.parse(path)

    if uri.scheme || uri.host || is_nil(uri.path) || uri.path == "" do
      path
    else
      original = Path.expand(URI.decode(uri.path), Path.dirname(Path.expand(source)))

      target =
        Map.get(sources, original) ||
          Map.get(sources, Path.expand(URI.decode(uri.path))) ||
          if(MapSet.member?(pages, uri.path), do: String.replace_suffix(uri.path, ".html", ".md"))

      if target, do: URI.to_string(%{uri | path: target}), else: path
    end
  end
end