defmodule SferaDoc do @moduledoc """ PDF generation with versioned Liquid templates. SferaDoc combines: - **Storage**: Templates in Ecto/ETS/Redis with automatic versioning - **Parsing**: Liquid templates via [`solid`](https://hex.pm/packages/solid) (default, pluggable), cached in ETS - **Rendering**: HTML to PDF via [`chromic_pdf`](https://hex.pm/packages/chromic_pdf) (default, pluggable) - **Cache**: Optional fast in-memory PDF cache (Redis/ETS) - **Object Store**: Optional durable PDF storage (S3/Azure/FileSystem) ## Quick Start # 1. Configure storage config :sfera_doc, :store, adapter: SferaDoc.Store.Ecto, repo: MyApp.Repo # 2. Add migration defmodule MyApp.Repo.Migrations.CreateSferaDocTemplates do use SferaDoc.Store.Ecto.Migration end # 3. Create template {:ok, template} = SferaDoc.create_template( "invoice", "
Amount: {{ amount }}
", variables_schema: %{"required" => ["customer_name", "amount"]} ) # 4. Render to PDF {:ok, pdf_binary} = SferaDoc.render("invoice", %{ "customer_name" => "Acme Corp", "amount" => "$1,200.00" }) File.write!("invoice.pdf", pdf_binary) ## Storage Backends Storage backends persist **template source code** and its metadata (name, version, variables_schema). This is separate from PDF storage templates are the input, PDFs are the output. | Adapter | Use case | |---|---| | `SferaDoc.Store.Ecto` | Production (PostgreSQL, MySQL, SQLite) | | `SferaDoc.Store.ETS` | Development/testing only | | `SferaDoc.Store.Redis` | Distributed systems | ## Two-Tier PDF Storage SferaDoc uses a two-tier storage system for rendered PDFs: 1. **Cache** (fast, in-memory) - First lookup, short TTL 2. **Object store** (durable storage) - Second lookup, survives restarts ### Cache Fast in-memory cache. Supports Redis or ETS. Disabled by default. # Redis (multi-node, production) config :sfera_doc, :pdf_hot_cache, adapter: :redis, ttl: 60 # ETS (single-node, development) config :sfera_doc, :pdf_hot_cache, adapter: :ets, ttl: 300 Override Redis connection (reuses `:redis` config by default): config :sfera_doc, :pdf_hot_cache, adapter: :redis, ttl: 60, redis: [host: "cache.example.com", port: 6379] ### Object Store Durable storage for rendered PDFs. Available adapters: | Adapter | Storage | |---|---| | `SferaDoc.Pdf.ObjectStore.S3` | Amazon S3 / S3-compatible | | `SferaDoc.Pdf.ObjectStore.Azure` | Azure Blob Storage | | `SferaDoc.Pdf.ObjectStore.FileSystem` | Local/shared filesystem | Example S3 configuration: config :sfera_doc, :pdf_object_store, adapter: SferaDoc.Pdf.ObjectStore.S3, bucket: "my-pdfs", region: "us-east-1" For custom object store adapters, see **Pluggable Engines** below. > #### Warning {: .warning} > > PDFs can be 100 KB – 10 MB+. For Redis cache, set explicit TTL > and `maxmemory-policy allkeys-lru` to prevent memory issues. ## Versioning Each update creates a new version. Previous versions are preserved. iex> SferaDoc.create_template("template_name", "Hello {{ name }}!
", version: 1, is_active: true, variables_schema: %{"required" => ["name"]}, inserted_at: ~U[2026-03-06 19:31:09Z], updated_at: ~U[2026-03-06 19:31:09Z] }} = SferaDoc.create_template( "welcome_email", "Hello {{ name }}!
", variables_schema: %{"required" => ["name"]} ) """ @spec create_template(String.t(), String.t(), keyword()) :: {:ok, Template.t()} | {:error, any()} def create_template(name, body, opts \\ []) when is_binary(name) and is_binary(body) do Store.put(%Template{ name: name, body: body, variables_schema: Keyword.get(opts, :variables_schema) }) end @doc """ Creates a new version of an existing template. The new version is immediately set as active. The previous version is preserved and can be restored with `activate_version/2`. Accepts the same options as `create_template/3`. """ @spec update_template(String.t(), String.t(), keyword()) :: {:ok, Template.t()} | {:error, any()} def update_template(name, new_body, opts \\ []) do Store.put(%Template{ name: name, body: new_body, variables_schema: Keyword.get(opts, :variables_schema) }) end @doc """ Returns the active template for `name`, or a specific version. ## Options - `:version`: return a specific version number instead of the active one ## Examples {:ok, template} = SferaDoc.get_template("invoice") {:ok, v2} = SferaDoc.get_template("invoice", version: 2) """ @spec get_template(String.t(), keyword()) :: {:ok, Template.t()} | {:error, any()} def get_template(name, opts \\ []) do case Keyword.get(opts, :version) do nil -> Store.get(name) version -> Store.get_version(name, version) end end @doc """ Returns a list of all templates (latest active version per name). """ @spec list_templates() :: {:ok, [Template.t()]} | {:error, any()} def list_templates, do: Store.list() @doc """ Returns all versions of a template, ordered by version descending. """ @spec list_versions(String.t()) :: {:ok, [Template.t()]} | {:error, any()} def list_versions(name), do: Store.list_versions(name) @doc """ Activates a specific version of a template, deactivating the current one. Useful for rolling back to a previous version. ## Example {:ok, template} = SferaDoc.activate_version("invoice", 1) """ @spec activate_version(String.t(), pos_integer()) :: {:ok, Template.t()} | {:error, any()} def activate_version(name, version), do: Store.activate_version(name, version) @doc """ Deletes all versions of a template by name. This operation is irreversible. """ @spec delete_template(String.t()) :: :ok | {:error, any()} def delete_template(name), do: Store.delete(name) end