Thanks for Earmark! Happy user 
In case anyone else has a need to walk and modify the AST (and if I’m reading the changelog so far correctly, that is not yet a built-in part of Earmark), I wrote a function for that which is public domain. Written for Earmark 1.4.19 and tested only with that version so far:
@doc """
Walks an AST and allows you to process it (storing details in acc) and/or
modify it as it is walked.
The process_item_fn function is required. It takes two parameters, the
single item to process (which will either be a string or a 4-tuple) and
the accumulator, and returns a tuple {processed_item, updated_acc}.
Returning the empty list for processed_item will remove the item processed
the AST.
The process_list_fn function is optional and defaults to no modification of
items or accumulator. It takes two parameters, the list of items that
are the sub-items of a given element in the AST (or the top-level list of
items), and the accumulator, and returns a tuple
{processed_items_list, updated_acc}.
This function ends up returning {ast, acc}.
"""
def walk_and_modify_ast(items, acc, process_item_fn, process_list_fn \\ &({&1, &2}))
when is_list(items) and is_function(process_item_fn) and is_function(process_list_fn)
do
{items, acc} = process_list_fn.(items, acc)
{ast, acc} = Enum.map_reduce(items, acc, fn (item, acc) ->
{_item, _acc} = walk_and_modify_ast_item(item, acc, process_item_fn, process_list_fn)
end)
{List.flatten(ast), acc}
end
def walk_and_modify_ast_item(item, acc, process_item_fn, process_list_fn)
when is_function(process_item_fn) and is_function(process_list_fn) do
case process_item_fn.(item, acc) do
{{type, attribs, items, annotations}, acc}
when is_binary(type) and is_list(attribs) and is_list(items) and is_map(annotations) ->
{items, acc} = walk_and_modify_ast(items, acc, process_item_fn, process_list_fn)
{{type, attribs, List.flatten(items), annotations}, acc}
{item_or_items, acc} when is_binary(item_or_items) or is_list(item_or_items) ->
{item_or_items, acc}
end
end
You would use it something like follows, this example is from my own code where I’m fixing up a non-standard markdown format that uses * and / for strong and italic):
def parse(some_markdown):
{:ok, ast, _} = EarmarkParser.as_ast(some_markdown)
ast
|> handle_bold()
|> handle_italics()
end
# We process the ast to replace "em" with "strong" (because "Bear *boldly* goes
# where no bear has gone before", and to find matching pairs of word-adjacent
# slashes and change to italic (because "Bear has a certain /je ne c'est quoi/
# to it").
def handle_italics(ast) do
ast
|> walk_and_modify_ast("", &handle_italics_impl/2)
|> elem(0)
end
def handle_italics_impl(item, "a"), do: {item, ""}
def handle_italics_impl(item, acc) when is_binary(item) do
new_item = text_to_ast_list_splitting_regex(
item,
~r/\/([[:graph:]].*?[[:graph:]]|[[:graph:]])\//,
fn [_, content] ->
{"em", [], [content], %{}}
end
)
{new_item, acc}
end
def handle_italics_impl({name, _, _, _} = item, _acc) do
# Store the last seen element name so we can skip handling
# italics within <a> elements.
{item, name}
end
def handle_bold(ast) do
ast
|> walk_and_modify_ast(0, &handle_bold_impl/2)
|> elem(0)
end
def handle_bold_impl({"em", attribs, items, annotations}, acc) do
{{"strong", attribs, items, annotations}, acc}
end
def handle_bold_impl(item, acc), do: {item, acc}