Current section

Files

Jump to
readmix lib readmix.ex
Raw

lib/readmix.ex

defmodule Readmix do
alias Readmix.BlockSpec
alias Readmix.Context
@moduledoc """
Readmix is a tool for generating and maintaining documentation with dynamic
content.
It allows you to embed special tags in your markdown or other text files that
will be processed and replaced with generated content.
## Basic Usage
```elixir
# Create a new Readmix instance
rdmx = Readmix.new([])
# Update a file containing Readmix blocks
Readmix.update_file(rdmx, "README.md")
```
## Block Format
Readmix blocks are defined in HTML comments with a special `rdmx` prefix:
```
<!-- rdmx my_namespace:my_action param1:"value1" param2:$my_var -->
This content will be replaced by the generator.
<!-- rdmx my_namespace:my_action -->
```
## Configuration
You can configure Readmix with custom generators, variables, and context
modules:
```elixir
Readmix.new(
generators: %{my_namespace: MyGeneratorModule},
vars: %{my_var: "hello"},
contexts: [MyContext, Readmix.Contexts.Defaults]
)
```
"""
defstruct [:resolver, :vars, :backup_fun]
@type t :: %__MODULE__{
resolver: function(),
backup_fun: function(),
vars: %{optional(atom) => term}
}
def new(opts) do
%__MODULE__{
resolver: opt_resolver(opts),
vars: opt_vars(opts),
backup_fun: opt_backup(opts)
}
end
@actions_schema NimbleOptions.new!(
*: [
type: :keyword_list,
keys: [
as: [type: :atom],
params: [
type: :keyword_list,
required: true
# TODO await https://github.com/dashbitco/nimble_options/issues/141
# keys: NimbleOptions.options_schema()
],
doc: [type: :string]
]
]
)
defp opt_resolver(opts) do
generators =
case opts[:generators] do
nil ->
Readmix.default_generators()
mods ->
mods
|> Map.new()
|> Map.put_new(:rdmx, Readmix.Generators.BuiltIn)
end
Map.new(generators, fn {ns, mod} ->
actions =
mod.actions()
|> NimbleOptions.validate!(@actions_schema)
|> Map.new(fn {key, spec} ->
params = NimbleOptions.new!(Keyword.fetch!(spec, :params))
{key, params}
end)
{ns, {mod, actions}}
end)
end
defp opt_vars(opts) do
external_vars =
case opts[:vars] do
vars when is_map(vars) -> vars
nil -> %{}
other -> raise "invalid option :vars, expected a map, got: #{inspect(other)}"
end
contexts =
case opts[:contexts] do
nil -> Readmix.default_contexts()
list when is_list(list) -> list
end
# Modules defined first in the list have precedence, subsequent modules do
# not overwrite their vars. The external vars have precedence over all
# modules.
Enum.reduce(contexts, external_vars, fn mod, acc ->
case mod.get_vars() do
map when is_map(map) ->
Map.merge(mod.get_vars(), acc)
other ->
raise "invalid return value from #{inspect(mod)}.get_vars/0, expected a map, got: #{inspect(other)}"
end
end)
end
defp opt_backup(opts) do
case opts[:backup?] do
true ->
backup_root_dir =
case opts[:backup_dir] do
nil -> raise ArgumentError, "option :backup_dir is required when :backup? is true"
dir -> dir
end
call_time =
case opts[:backup_datetime] do
%DateTime{} = dt -> dt
nil -> DateTime.utc_now()
end
make_backup_callback(backup_root_dir, call_time)
_ ->
_no_backup = fn _, _ -> :ok end
end
end
defp make_backup_callback(backup_root_dir, call_time) do
stamp = Calendar.strftime(call_time, "%x-%H-%M-%S-%f")
backup_dir = Path.join(backup_root_dir, "readmix-backup-#{stamp}")
File.mkdir_p!(backup_dir)
fn orginal_path, content ->
sub_path =
case orginal_path do
"/" <> sub_abs_path -> sub_abs_path
rel_path -> rel_path
end
target_path = Path.join(backup_dir, sub_path)
if File.exists?(target_path) do
raise "cannot backup #{orginal_path} to #{target_path}, file exists"
end
with :ok <- File.mkdir_p!(Path.dirname(target_path)),
:ok <- File.write(target_path, content) do
CliMate.CLI.writeln("Wrote backup of #{orginal_path} in #{target_path}")
:ok
else
{:error, _} = err -> err
end
end
end
@default_generators %{rdmx: Readmix.Generators.BuiltIn}
@doc """
Returns the mapping from namespace to generator used by default by Readmix if
no `:resolver` or `:generators` option is given to `Readmix.new/1`.
Those defaults are pulled with `Application.fetch_env(:readmix, :generators)`,
which is a configuration value that you can define in `config/dev.exs` or
`config/runtime.exs`.
If not configuration is defined, the default value is
`#{inspect(@default_generators)}`.
"""
def default_generators do
case Application.fetch_env(:readmix, :generators) do
{:ok, generators} when is_map(generators) -> generators
:error -> @default_generators
end
end
@default_contexts [Readmix.Contexts.Defaults]
@doc """
Returns the list of modules used to define the context of execution. For now
this only defines which variables are available for usage in blocks arguments.
Those defaults are pulled with `Application.fetch_env(:readmix, :contexts)`,
which is a configuration value that you can define in `config/dev.exs` or
`config/runtime.exs`.
If not configuration is defined, the default value is
`#{inspect(@default_contexts)}`.
"""
def default_contexts do
case Application.fetch_env(:readmix, :contexts) do
{:ok, contexts} when is_list(contexts) -> contexts
:error -> @default_contexts
end
end
def update_file(rdmx, path) do
with {:ok, content} <- read_source(path),
{:ok, iodata} <- transform_string(rdmx, content, source_path: path),
:ok <- call_backup(rdmx, path, content) do
File.write(path, iodata)
else
{:error, reason} -> {:error, convert_error(reason, path)}
end
end
defp read_source(path) do
case File.read(path) do
{:ok, content} -> {:ok, content}
{:error, reason} -> {:error, {:file_error, path, reason}}
end
end
defp call_backup(rdmx, path, content) do
rdmx.backup_fun.(path, content)
end
def transform_string(rdmx, string, opts \\ [])
def transform_string(rdmx, string, opts) do
with {:ok, iodata} <- transform_string_to_iodata(rdmx, string, opts) do
{:ok, IO.iodata_to_binary(iodata)}
end
end
def transform_string_to_iodata(rdmx, string, opts \\ [])
def transform_string_to_iodata(rdmx, string, opts) when is_binary(string) do
with {:ok, blocks} <- parse_string(string, opts[:source_path]) do
blocks_to_iodata(rdmx, blocks)
end
end
def parse_string(string, source_path \\ nil)
def parse_string(string, nil) do
parse_string(string, "nofile")
end
def parse_string(string, source_path) do
Readmix.Parser.parse_string(string, source_path)
end
def blocks_to_iodata(rdmx, blocks) do
result =
Enum.reduce_while(blocks, {:ok, []}, fn block, {:ok, acc} ->
case block_to_iodata(rdmx, block) do
{:ok, new_block} -> {:cont, {:ok, [new_block | acc]}}
{:error, _} = err -> {:halt, err}
end
end)
case result do
{:ok, rev} -> {:ok, :lists.reverse(rev)}
{:error, _} = err -> err
end
end
defp block_to_iodata(_rdmx, {:text, iodata}), do: {:ok, iodata}
defp block_to_iodata(rdmx, {:generated, block}) do
%{
generator: {ns, action, params},
raw_header: raw_header,
raw_footer: raw_footer,
content: sub_blocks
} = block
# We do not use the :as param of an action. This is for the macro. We will
# always call the generate/3 callback.
try do
with {:ok, {mod, params}} <- resolve_call(rdmx, ns, action, params),
{:ok, iodata} <- call_transformer(rdmx, mod, action, params, sub_blocks) do
{:ok, [raw_header, iodata, raw_footer]}
else
{:error, reason} -> {:error, convert_error(reason, block)}
end
catch
:throw, {:undef_var, _} = e -> {:error, convert_error(e, block)}
end
end
defp resolve_call(rdmx, ns, action, params_in) do
with {:ok, {mod, actions}} <- resolve_mod(rdmx, ns),
errctx = {ns, mod, action},
{:ok, params_schema} <- resolve_fun(actions, action, errctx),
{:ok, params} <- swap_variables(rdmx, params_in),
{:ok, params} <- validate_params(params, params_schema, errctx) do
{:ok, {mod, params}}
end
end
defp resolve_mod(rdmx, ns) do
case Map.fetch(rdmx.resolver, ns) do
{:ok, {mod, actions}} when is_atom(mod) ->
{:ok, {mod, actions}}
{:ok, other} ->
raise "invalid mapped generator: #{inspect(other)}"
:error ->
{:error, {:unresolved_generator, ns}}
end
end
defp resolve_fun(actions, action, errctx) do
case Map.fetch(actions, action) do
{:ok, %NimbleOptions{}} = found ->
found
:error ->
{:error, {:unknown_action, {action, errctx}}}
end
end
defp swap_variables(rdmx, params) do
%{vars: vars} = rdmx
swapped =
Enum.map(params, fn
{k, {:var, var}} -> {k, Readmix.Generator.expect_variable(vars, var)}
{k, v} -> {k, v}
end)
{:ok, swapped}
end
defp validate_params(params, params_schema, errctx) do
case NimbleOptions.validate(params, params_schema) do
{:ok, _} = fine ->
fine
{:error, validation_error} ->
{:error, {:params_validation_error, {validation_error, errctx}}}
end
end
# {:ok, mod} -> {:ok, mod}
# :error when ns == :rdmx -> {:ok, Readmix.Generators.BuiltIn}
# :error -> {:error, {:unresolved_generator, ns}}
# other -> raise "invalid resolver return value: #{inspect(other)}"
defp call_transformer(rdmx, mod, action, params, previous_content) do
context = %Context{previous_content: previous_content, readmix: rdmx}
case mod.generate(action, params, context) do
{:ok, iodata} -> {:ok, iodata}
{:error, reason} -> {:error, {:generator_error, {mod, action, params, reason}}}
other -> {:error, {:invalid_generator_return, {mod, action, params, other}}}
end
end
def format_error(%Readmix.Parser.ParseError{} = e) do
Readmix.Parser.ParseError.message(e)
end
def format_error(%Readmix.Error{} = e) do
Readmix.Error.message(e)
end
defp convert_error(reason, %BlockSpec{} = block) do
convert_error(reason, block.file, block.loc)
end
defp convert_error(reason, path) when is_binary(path) do
convert_error(reason, path, nil)
end
defp convert_error(%Readmix.Error{} = e, _path, _loc) do
e
end
defp convert_error(%Readmix.Parser.ParseError{} = e, _path, _loc) do
e
end
defp convert_error(reason, path, loc) do
Readmix.Error.convert(reason, path, loc)
end
end