defprotocol AshTypst.Code do
@moduledoc """
Protocol to support Typst code syntax.
"""
@doc """
Encode Elixir data structures into Typst code syntax.
## Examples
iex> AshTypst.Code.encode(~U[2015-01-13 13:00:07Z], %{timezone: "America/New_York"})
"datetime(year: 2015, month: 1, day: 13, hour: 8, minute: 0, second: 7)"
iex> AshTypst.Code.encode(nil, %{})
"none"
iex> AshTypst.Code.encode(%{true: true, false: false, other: :other}, %{})
"(\\"false\\": false, \\"true\\": true, \\"other\\": \\"other\\")"
iex> AshTypst.Code.encode(["one", 2, 3.0], %{})
"(\\"one\\", int(2), float(3.0))"
The following types are supported by default:
- `Map` -> [`dictionary`](https://typst.app/docs/reference/foundations/dictionary/)
- `List` -> [`array`](https://typst.app/docs/reference/foundations/array/)
- `Decimal` -> [`decimal`](https://typst.app/docs/reference/foundations/decimal/)
- `DateTime` -> [`datetime`](https://typst.app/docs/reference/foundations/datetime/)
- `NaiveDateTime` -> [`datetime`](https://typst.app/docs/reference/foundations/datetime/)
- `Date` -> [`datetime`](https://typst.app/docs/reference/foundations/datetime/)
- `Time` -> [`datetime`](https://typst.app/docs/reference/foundations/datetime/)
- `Integer` -> [`int`](https://typst.app/docs/reference/foundations/int/)
- `Float` -> [`float`](https://typst.app/docs/reference/foundations/float/)
- `String` -> [`str`](https://typst.app/docs/reference/foundations/str/)
- `Atom` converts one of several Typst types:
- `nil` -> [`none`](https://typst.app/docs/reference/foundations/none/)
- `true`/`false` -> [`bool`](https://typst.app/docs/reference/foundations/bool/)
- All others -> [`str`](https://typst.app/docs/reference/foundations/str/)
- `Ash.Resource` (public fields) -> [`dictionary`](https://typst.app/docs/reference/foundations/dictionary/)
- `Ash.NotLoaded` -> [`none`](https://typst.app/docs/reference/foundations/none/)
- `Ash.CiString` -> [`str`](https://typst.app/docs/reference/foundations/str/)
Structs (including Ash resources) are not encoded unless they opt in. Add
`@derive AshTypst.Code` to the module to use the built-in implementation
(which serializes an Ash resource's public fields), or implement the protocol
directly with `defimpl AshTypst.Code, for: MyStruct` for full control:
```elixir
defmodule MyApp.Invoice do
use Ash.Resource, domain: MyApp.Domain
@derive AshTypst.Code
# ...
end
```
Context must be passed through. This allows for things like dates to be formatted according to a given timezone, etc.
If `timezone` is specified in the context, supported types will be automatically shifted to that zone. Ensure you install and configure your choice of timezone database in `config.exs`:
```elixir
config :elixir, :time_zone_database, Tzdata.TimeZoneDatabase
config :elixir, :time_zone_database, TimeZoneInfo.TimeZoneDatabase
config :elixir, :time_zone_database, Zoneinfo.TimeZoneDatabase
config :elixir, :time_zone_database, Tz.TimeZoneDatabase
```
"""
def encode(value, context)
end
defimpl AshTypst.Code, for: Any do
def encode(%{} = map, _context) when map_size(map) == 0, do: "(:)"
def encode(%{__struct__: module} = map, %{struct_keys: struct_keys} = context) do
stripped =
case struct_keys do
%{^module => keys} -> Map.take(map, keys)
_ -> auto_strip(map)
end
AshTypst.Code.encode(stripped, context)
end
def encode(map, context) do
stripped = auto_strip(map)
AshTypst.Code.encode(stripped, context)
end
@struct_drop_keys [:__struct__]
defp auto_strip(%{__struct__: module} = map) do
if Ash.Resource.Info.resource?(module) do
strip_ash_resource(map)
else
Map.drop(map, @struct_drop_keys)
end
end
defp strip_ash_resource(map) do
loadable_keys =
map.__struct__
|> Ash.Resource.Info.public_fields()
|> Enum.reduce([], fn
%{name: name, type: :attribute}, acc ->
if name in map.__metadata__.selected, do: [name | acc], else: acc
%{name: name}, acc ->
[name | acc]
end)
Map.take(map, [:calculations, :aggregates] ++ loadable_keys)
end
end
defimpl AshTypst.Code, for: Map do
def encode(%{} = map, _context) when map_size(map) == 0, do: "(:)"
def encode(map, context) do
fields =
Enum.map_join(map, ", ", fn
{key, value} -> "\"#{key}\": " <> AshTypst.Code.encode(value, context)
end)
"(#{fields})"
end
end
defimpl AshTypst.Code, for: List do
def encode([], _context), do: "()"
def encode([value], context), do: "(#{AshTypst.Code.encode(value, context)},)"
def encode(list, context) do
fields =
Enum.map_join(list, ", ", fn value -> AshTypst.Code.encode(value, context) end)
"(#{fields})"
end
end
defimpl AshTypst.Code, for: DateTime do
def encode(datetime, context) do
timezone = Map.get(context, :timezone, "Etc/UTC")
%{year: year, month: month, day: day, hour: hour, minute: minute, second: second} =
DateTime.shift_zone!(datetime, timezone)
"datetime(year: #{year}, month: #{month}, day: #{day}, hour: #{hour}, minute: #{minute}, second: #{second})"
end
end
defimpl AshTypst.Code, for: NaiveDateTime do
def encode(
%{year: year, month: month, day: day, hour: hour, minute: minute, second: second},
_context
) do
"datetime(year: #{year}, month: #{month}, day: #{day}, hour: #{hour}, minute: #{minute}, second: #{second})"
end
end
defimpl AshTypst.Code, for: Date do
def encode(%{year: year, month: month, day: day}, _context) do
"datetime(year: #{year}, month: #{month}, day: #{day})"
end
end
defimpl AshTypst.Code, for: Time do
def encode(
%{hour: hour, minute: minute, second: second},
_context
) do
"datetime(hour: #{hour}, minute: #{minute}, second: #{second})"
end
end
defimpl AshTypst.Code, for: Integer do
def encode(integer, _context), do: "int(#{integer})"
end
defimpl AshTypst.Code, for: Float do
def encode(float, _context), do: "float(#{float})"
end
defimpl AshTypst.Code, for: BitString do
def encode(string, _context) do
escaped =
string
|> String.replace("\\", "\\\\")
|> String.replace("\"", "\\\"")
|> String.replace("\n", "\\n")
|> String.replace("\r", "\\r")
|> String.replace("\t", "\\t")
"\"#{escaped}\""
end
end
defimpl AshTypst.Code, for: Atom do
def encode(nil, _context), do: "none"
def encode(true, _context), do: "true"
def encode(false, _context), do: "false"
def encode(atom, context), do: atom |> Atom.to_string() |> AshTypst.Code.encode(context)
end
defimpl AshTypst.Code, for: Decimal do
def encode(decimal, _context), do: "decimal(\"#{decimal}\")"
end
defimpl AshTypst.Code, for: Ash.NotLoaded do
def encode(_, _context), do: "none"
end
defimpl AshTypst.Code, for: Ash.CiString do
def encode(%{string: string}, context), do: AshTypst.Code.encode(string, context)
end