defmodule FancyFences do @moduledoc ~S''' Post-process code blocks in your markdown docs. `FancyFences` is a tiny wrapper around `ExDoc.Markdown.Earmark` that post processes fenced markdown code blocks. You can use it to: - Ensure that the code examples are up to date with your code. - Automatically inject the output of the code block. - Enrich your docs with VegaLite plots or mermaid charts. - Use it in a more hacky way when interpolation is not possible. ## Usage In order to use `FancyFences` you need to set the `:markdown_processor` option in the `:docs` section as following: docs: [ markdown_processor: {FancyFences, [fences: processors]} ] where `processors` is expected to be a map with all processors that will be applied on code blocks. The keys of the `proecessors` map are the code _language_ on which this specific processor will be applied. The values of the `processors` map are expected to be `mfa` tuples with the function that will be applied. For example the following configuration: docs: [ markdown_processor: {FancyFences, [fences: fancy_processors()]} ] defp fancy_processors do %{ "elixir" => {FancyFences.Processors, :format_code, []}, "elixir::inspect" => FancyFences.Processors, :inspect_code, []) } end will apply two processors: - Each `elixir` code block will be post-processed using the `FancyFences.Processors.format_code/1` which will format the inline code. - Each `elixir::inspect` code block will be post-processed using the `FancyFences.Processors.inspect_code/2` which will replace the existing code block with two blocks: - An `:elixir` code block including the initial code - A second `:elixir` block with the output of the evaluation of the first block. This is useful for evaluating during docs generation fenced code blocks, without having to manually write the expected output, also ensuring that your docs are always up to date, since if the underlying functions change then `mix docs` will fail. > #### Recursive application of fence processors {: .tip} > > Norice that fence processors are applied recursively. This means that in the previous > example the `elixir` injected code blocks will also be processed by the format > processor, ensuring that both the raw code input and the evaluation output will be > properly formatted. ## Fence processors Fence processors are simple elixir functions that expect as input a string corresponding to the code in the markdown code block and an optional keyword list and return a markdown that will be injected in the docs. ### Writing a custom processor Let's see a real world example, how [`Tucan`](https://hexdocs.pm/tucan/Tucan.html) uses `FancyFences` in order to render both the sample code and the corresponding `vega-lite` specification. `Tucan` defines a fence processor that it expects a code blocks that returns a `VegaLite` struct and it generates two code blocks: - The original code formatted. - The `json` `vega-lite` specification of the `VegaLite` struct. ~~~elixir def tucan(code, _opts \\ []) do {%VegaLite{} = plot, _} = Code.eval_string(code, [], __ENV__) spec = VegaLite.to_spec(plot) """ ```elixir #{Code.format_string!(code)} ``` ```vega-lite #{Jason.encode!(spec)} ``` """ end ~~~ In order to enable it you only need to configure to which markdown code _language_ this will be applied. In the `Tucan` case it is applied on code blocks marked as `tucan`: docs: [ markdown_processor: {FancyFences, [ fences: %{ "tucan" => {Tucan.Docs, :tucan, []} } ]}, ] ''' @behaviour ExDoc.Markdown @impl ExDoc.Markdown def available?, do: ExDoc.Markdown.Earmark.available?() @impl ExDoc.Markdown def to_ast(text, opts) do to_ast(text, opts, []) end defp to_ast(text, opts, applied_processors) do {_fences_opts, doc_opts} = Keyword.split(opts, [:fences]) text |> ExDoc.Markdown.Earmark.to_ast(doc_opts) |> Enum.reduce([], fn block, acc -> acc ++ maybe_apply_fence_processors(block, opts, applied_processors) end) end defp maybe_apply_fence_processors( {:pre, _pre_attrs, [{:code, [class: fence], content, _code_meta}], _pre_meta} = block, opts, applied_processors ) do fence_processors = Keyword.get(opts, :fences, %{}) cond do # we want to avoid infinite recursion here, just return the block fence in applied_processors -> [block] # if we have defined a custom fence processor apply it (recursively) Map.has_key?(fence_processors, fence) -> # a configured fence processor must be an mfa tuple {module, function, args} = fence_processors[fence] code = Enum.join(content, "\n") apply(module, function, [code | args]) |> to_ast(opts, [fence | applied_processors]) # in any other case return the original block true -> [block] end end defp maybe_apply_fence_processors( {type, attrs, content, meta}, opts, applied_processors ) do content = Enum.reduce(content, [], fn block, acc -> acc ++ maybe_apply_fence_processors(block, opts, applied_processors) end) [{type, attrs, content, meta}] end defp maybe_apply_fence_processors(block, _opts, _applied_processors), do: [block] end