Current section
Files
Jump to
Current section
Files
lib/ash_typst_code.ex
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
```
## Query-level compaction
The built-in Ash resource implementation carries only the data your query
actually produced. Every public field appears as a key in the emitted
dictionary, but the *values* follow the query:
- **Private fields are excluded entirely** — only `public?: true` fields
are considered.
- **Anything the query did not produce is omitted.** Attributes excluded
via `Ash.Query.select/2` (or the `select` option of a render action's
`read` block) and relationships, calculations, or aggregates that were
not loaded do not appear in the dictionary at all — not even as `none`.
- **Loaded-but-empty is preserved.** A field the query *did* produce with
an empty result (e.g. a `belongs_to` that resolved to `nil`) encodes as
`none`, keeping "no value" distinct from "not queried".
- **Forbidden fields are omitted silently.** A field redacted by a field
policy (`Ash.ForbiddenField`) is dropped, the same as not-loaded.
- **Anonymous calculations and aggregates live under `calculations` /
`aggregates`.** Calculations and aggregates loaded ad hoc on the query
(rather than declared on the resource) are encoded as dictionaries under
those two keys; when empty, the keys are dropped.
The encoded output is Typst *source code* that the compiler must parse and
evaluate on every compile, so payload size translates directly into
compilation time. Because the payload follows the query, it is ideal to
de-select attributes your template does not use — especially for large
documents rendering thousands of records, where a few unused text columns
can multiply the data the Typst compiler has to chew through:
```elixir
read :many do
select [:name, :amount, :inserted_at]
load [:line_items]
end
```
Ideally a template references only fields its query provides, so absent
keys are never an issue. If a single template is shared by actions with
*different* query loads, read the varying fields with a default:
`record.at("field", default: none)`.
## Key order
Dictionary keys are emitted in Erlang's native map iteration order — they
are deliberately *not* sorted, to keep encoding fast for large datasets.
That order is not defined across VM runs, so templates should access fields
by name (or sort explicitly when iterating) rather than rely on key order,
and byte-identical output across runs is not guaranteed.
To override which keys are encoded for a given struct, pass `struct_keys`
in the context — a map of struct module to the exact keys to take:
AshTypst.Code.encode(record, %{struct_keys: %{MyApp.Invoice => [:id, :total]}})
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
public_keys = Enum.map(Ash.Resource.Info.public_fields(map.__struct__), & &1.name)
map
|> Map.take([:calculations, :aggregates] ++ public_keys)
|> Map.reject(fn
{_key, %Ash.NotLoaded{}} -> true
{_key, %Ash.ForbiddenField{}} -> true
{key, value} when key in [:calculations, :aggregates] -> value == %{}
{_key, _value} -> false
end)
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