Files
bDS2/lib/bds/frontmatter.ex

275 lines
7.0 KiB
Elixir

defmodule BDS.Frontmatter do
@moduledoc false
alias BDS.Persistence
@list_item_prefix " - "
@block_scalar_indent " "
def serialize_document(fields, body) when is_list(fields) do
frontmatter =
fields
|> Enum.flat_map(&serialize_field/1)
|> Enum.join("\n")
["---", frontmatter, "---", body || "", ""]
|> Enum.join("\n")
end
def parse_document(contents) when is_binary(contents) do
normalized_contents = normalize_newlines(contents)
case String.split(normalized_contents, "\n---\n", parts: 2) do
[frontmatter_with_marker, body] ->
frontmatter = String.replace_prefix(frontmatter_with_marker, "---\n", "")
{:ok,
%{
fields: parse_frontmatter(frontmatter),
body: String.trim_trailing(body, "\n")
}}
_parts ->
{:error, :invalid_frontmatter}
end
end
defp serialize_field({_key, nil}), do: []
defp serialize_field({_key, ""}), do: []
defp serialize_field({key, false}) do
["#{key}: false"]
end
defp serialize_field({key, true}) do
["#{key}: true"]
end
defp serialize_field({key, values}) when is_list(values) do
["#{key}:" | Enum.map(values, &" - #{serialize_scalar(nil, &1)}")]
end
defp serialize_field({key, value}) when is_atom(value) do
["#{key}: #{Atom.to_string(value)}"]
end
defp serialize_field({key, value}) when is_integer(value) do
rendered =
if timestamp_key?(key) do
Persistence.timestamp_to_iso8601(value)
else
Integer.to_string(value)
end
if timestamp_key?(key) do
["#{key}: '#{rendered}'"]
else
["#{key}: #{rendered}"]
end
end
defp serialize_field({key, value}) do
["#{key}: #{serialize_scalar(key, value)}"]
end
defp parse_frontmatter(frontmatter) do
frontmatter
|> String.split("\n", trim: true)
|> parse_lines(%{})
end
defp parse_lines([], acc), do: acc
defp parse_lines([line | rest], acc) do
cond do
String.starts_with?(line, @list_item_prefix) ->
parse_lines(rest, acc)
String.ends_with?(line, ":") ->
key = String.trim_trailing(line, ":")
{items, remaining} = take_list_items(rest, [])
parse_lines(remaining, Map.put(acc, key, Enum.reverse(items)))
String.contains?(line, ": ") ->
[key, raw_value] = String.split(line, ": ", parts: 2)
if block_scalar_indicator?(raw_value) do
{value_lines, remaining} = take_block_scalar_lines(rest, [])
value = parse_scalar(key, parse_block_scalar(raw_value, value_lines))
parse_lines(remaining, Map.put(acc, key, value))
else
parse_lines(rest, Map.put(acc, key, parse_scalar(key, raw_value)))
end
true ->
parse_lines(rest, acc)
end
end
defp take_list_items([line | rest], items) do
if String.starts_with?(line, @list_item_prefix) do
value = line |> String.replace_prefix(@list_item_prefix, "") |> then(&parse_scalar(nil, &1))
take_list_items(rest, [value | items])
else
{items, [line | rest]}
end
end
defp take_list_items([], items), do: {items, []}
defp take_block_scalar_lines([line | rest], lines) do
if String.starts_with?(line, @block_scalar_indent) do
take_block_scalar_lines(rest, [
String.replace_prefix(line, @block_scalar_indent, "") | lines
])
else
{Enum.reverse(lines), [line | rest]}
end
end
defp take_block_scalar_lines([], lines), do: {Enum.reverse(lines), []}
defp parse_scalar(key, value) when is_binary(key) and is_binary(value) do
trimmed = String.trim(value)
parsed = parse_generic_scalar(trimmed)
cond do
timestamp_key?(key) ->
if is_binary(parsed) do
Persistence.parse_timestamp(parsed) || parsed
else
parsed
end
true ->
parsed
end
end
defp parse_scalar(nil, value) when is_binary(value) do
value
|> String.trim()
|> parse_generic_scalar()
end
defp parse_generic_scalar("true"), do: true
defp parse_generic_scalar("false"), do: false
defp parse_generic_scalar("[]"), do: []
defp parse_generic_scalar(value) do
if Regex.match?(~r/^-?\d+$/, value) do
String.to_integer(value)
else
parse_string(value)
end
end
defp parse_string("\"" <> rest) do
parse_quoted_string(rest, ?")
end
defp parse_string("'" <> rest) do
parse_quoted_string(rest, ?')
end
defp parse_string(value), do: value
defp block_scalar_indicator?(value) do
trimmed = String.trim(value)
String.starts_with?(trimmed, ">") or String.starts_with?(trimmed, "|")
end
defp parse_block_scalar(indicator, lines) do
trimmed = String.trim(indicator)
cond do
String.starts_with?(trimmed, ">") -> Enum.join(lines, " ")
String.starts_with?(trimmed, "|") -> Enum.join(lines, "\n")
true -> Enum.join(lines, "\n")
end
end
defp serialize_scalar(_key, value) when is_boolean(value) do
if(value, do: "true", else: "false")
end
defp serialize_scalar(_key, value) when is_atom(value) do
Atom.to_string(value)
end
defp serialize_scalar(key, value) when is_integer(value) do
if is_binary(key) and timestamp_key?(key) do
"'#{Persistence.timestamp_to_iso8601(value)}'"
else
Integer.to_string(value)
end
end
defp serialize_scalar(_key, value) do
value
|> to_string()
|> maybe_quote_string()
end
defp maybe_quote_string(value) do
if Regex.match?(~r/^[\p{L}\p{N} ._\/-]+$/u, value) do
value
else
escaped =
value
|> String.replace("\\", "\\\\")
|> String.replace("\"", "\\\"")
|> String.replace("\n", "\\n")
"\"#{escaped}\""
end
end
defp timestamp_key?(key) do
rendered = to_string(key)
String.ends_with?(rendered, "_at") or String.ends_with?(rendered, "At")
end
defp normalize_newlines(contents) do
contents
|> String.replace("\r\n", "\n")
|> String.replace("\r", "\n")
end
defp parse_quoted_string(rest, quote) do
quote_binary = <<quote::utf8>>
if String.ends_with?(rest, quote_binary) do
inner = binary_part(rest, 0, byte_size(rest) - byte_size(quote_binary))
unescape_quoted_string(inner, quote, "")
else
quote_binary <> rest
end
end
defp unescape_quoted_string(<<>>, _quote, acc), do: acc
defp unescape_quoted_string("\\" <> rest, quote, acc) do
case rest do
<<"n", tail::binary>> ->
unescape_quoted_string(tail, quote, acc <> "\n")
<<"\\", tail::binary>> ->
unescape_quoted_string(tail, quote, acc <> "\\")
<<escaped, tail::binary>> when escaped == quote ->
unescape_quoted_string(tail, quote, acc <> <<quote::utf8>>)
<<char::utf8, tail::binary>> ->
unescape_quoted_string(tail, quote, acc <> "\\" <> <<char::utf8>>)
<<>> ->
acc <> "\\"
end
end
defp unescape_quoted_string(<<char::utf8, tail::binary>>, quote, acc) do
unescape_quoted_string(tail, quote, acc <> <<char::utf8>>)
end
end