<h1 style="color:#7C3AED; font-weight:400; letter-spacing:0.5px;">
<span style="font-weight:700;">Sfera</span>Doc
</h1>
PDF generation library for Elixir. Store versioned [Liquid](https://shopify.github.io/liquid/) templates in your database, render them to PDF via Chrome.
## Features
- **Template storage**: Liquid templates stored with full version history (Ecto, ETS, or Redis)
- **Template parsing**: Powered by [`solid`](https://hex.pm/packages/solid); parsed ASTs are cached in ETS
- **PDF rendering**: HTML rendered by [`chromic_pdf`](https://hex.pm/packages/chromic_pdf) (Chrome-based)
- **PDF storage**: Two-tier: Redis/ETS hot cache + durable object store (S3, Azure Blob, or file system)
- **Variable validation**: Declare required variables per template and get clear errors before rendering
## Installation
```elixir
def deps do
[
{:sfera_doc, "~> 0.1.0"},
# Required if using the Ecto storage backend
{:ecto_sql, "~> 3.10"},
{:postgrex, ">= 0.0.0"}, # or :myxql / :ecto_sqlite3
# Required if using the Redis storage backend
{:redix, "~> 1.1"},
# Required for PDF rendering
{:chromic_pdf, "~> 1.14"},
# Required if using the S3 PDF object store
{:ex_aws, "~> 2.5"},
{:ex_aws_s3, "~> 2.5"},
# Required if using the Azure Blob PDF object store
{:azurex, "~> 1.1"}
]
end
```
## Setup
### 1. Configure a storage backend
```elixir
# config/config.exs
# Ecto (recommended for production)
config :sfera_doc, :store,
adapter: SferaDoc.Store.Ecto,
repo: MyApp.Repo
# Redis
config :sfera_doc, :store, adapter: SferaDoc.Store.Redis
config :sfera_doc, :redis, host: "localhost", port: 6379
# ETS: dev/test only, data is lost on restart
config :sfera_doc, :store, adapter: SferaDoc.Store.ETS
```
### 2. Create the database table (Ecto only)
Create a new mgiration, and copy the example migration from [`priv/migrations/create_sfera_doc_templates.exs`](priv/migrations/create_sfera_doc_templates.exs), then run the migration.
The example includes comments for PostgreSQL-specific parts (the `pgcrypto` extension and the partial unique index). Remove those if you are using MySQL or SQLite.
## Usage
### Create a template
```elixir
{:ok, template} = SferaDoc.create_template(
"invoice",
"""
<html>
<body>
<h1>Invoice #{{ number }}</h1>
<p>Bill to: {{ customer_name }}</p>
<p>Amount due: {{ amount }}</p>
</body>
</html>
""",
variables_schema: %{
"required" => ["number", "customer_name", "amount"]
}
)
```
### Render to PDF
```elixir
{:ok, pdf_binary} = SferaDoc.render("invoice", %{
"number" => "INV-0042",
"customer_name" => "Acme Corp",
"amount" => "$1,200.00"
})
File.write!("invoice.pdf", pdf_binary)
```
### Missing variables
If required variables are absent, rendering is short-circuited before any parsing or Chrome calls:
```elixir
{:error, {:missing_variables, ["amount"]}} =
SferaDoc.render("invoice", %{"number" => "1", "customer_name" => "Acme"})
```
### Template versioning
Every `update_template/3` call creates a new version and activates it. Previous versions are preserved.
```elixir
{:ok, v1} = SferaDoc.create_template("report", "<p>Draft</p>")
{:ok, v2} = SferaDoc.update_template("report", "<p>Final</p>")
# List all versions
{:ok, versions} = SferaDoc.list_versions("report")
# => [%Template{version: 2, is_active: true}, %Template{version: 1, is_active: false}]
# Render a specific version
{:ok, pdf} = SferaDoc.render("report", %{}, version: 1)
# Roll back to a previous version
{:ok, _} = SferaDoc.activate_version("report", 1)
```
### Other operations
```elixir
# Fetch template metadata (no rendering)
{:ok, template} = SferaDoc.get_template("invoice")
{:ok, template} = SferaDoc.get_template("invoice", version: 2)
# List all templates (active version per name)
{:ok, templates} = SferaDoc.list_templates()
# Delete all versions of a template
:ok = SferaDoc.delete_template("invoice")
```
## Configuration Reference
```elixir
# Storage backend (required)
config :sfera_doc, :store,
adapter: SferaDoc.Store.Ecto,
repo: MyApp.Repo,
table_name: "sfera_doc_templates" # optional, compile-time
# Redis connection (when using Redis adapter)
config :sfera_doc, :redis,
host: "localhost",
port: 6379
# Or with a URI:
config :sfera_doc, :redis, "redis://localhost:6379"
# Parsed template AST cache (default: enabled, 300s TTL)
config :sfera_doc, :cache,
enabled: true,
ttl: 300
# ChromicPDF options (passed through to ChromicPDF supervisor)
config :sfera_doc, :chromic_pdf,
session_pool: [size: 2, timeout: 10_000]
# Template engine adapter (defaults to Solid)
config :sfera_doc, :template_engine,
adapter: SferaDoc.TemplateEngine.Solid
# PDF engine adapter (defaults to ChromicPDF)
config :sfera_doc, :pdf_engine,
adapter: SferaDoc.PdfEngine.ChromicPDF
```
### PDF hot cache (opt-in)
A fast, ephemeral front-tier cache. Repeat requests with identical variables are
served from Redis or ETS without touching the object store or Chrome.
```elixir
# Redis (distributed, recommended for multi-node)
config :sfera_doc, :pdf_hot_cache,
adapter: :redis,
ttl: 60 # seconds
# ETS (single-node, zero external deps)
config :sfera_doc, :pdf_hot_cache,
adapter: :ets,
ttl: 300
```
> **Warning:** PDFs can be 100 KB – 10 MB or more. Keep TTLs short and monitor memory.
> For Redis, set `maxmemory-policy allkeys-lru`.
### PDF object store (opt-in)
A durable, persistent tier, the **source of truth** for rendered PDFs. PDFs survive
BEAM restarts. On a cache hit the PDF is returned directly and the hot cache is
populated, avoiding Chrome entirely.
```elixir
# Amazon S3 (requires :ex_aws and :ex_aws_s3)
config :sfera_doc, :pdf_object_store,
adapter: SferaDoc.Pdf.ObjectStore.S3,
bucket: "my-pdfs",
prefix: "sfera_doc/" # optional
# Azure Blob Storage (requires :azurex)
config :sfera_doc, :pdf_object_store,
adapter: SferaDoc.Pdf.ObjectStore.Azure,
container: "my-pdfs"
# azurex credentials (can also be passed inline in the config above)
config :azurex, Azurex.Blob.Config,
storage_account_name: "mystorageaccount",
storage_account_key: "base64encodedkey=="
# Local / shared file system (no extra deps)
config :sfera_doc, :pdf_object_store,
adapter: SferaDoc.Pdf.ObjectStore.FileSystem,
path: "/var/data/pdfs"
```
Both tiers are fully independent. You can use the object store without a hot cache,
the hot cache without the object store, both together, or neither (generate on every
request, the original behaviour).
## Storage Backends
| Adapter | Use case |
|---|---|
| `SferaDoc.Store.Ecto` | Production: PostgreSQL, MySQL, SQLite |
| `SferaDoc.Store.ETS` | Development and testing only (data lost on restart) |
| `SferaDoc.Store.Redis` | Distributed / Redis-heavy stacks |
## Telemetry
SferaDoc emits the following telemetry events:
| Event | Measurements | Metadata |
|---|---|---|
| `[:sfera_doc, :render, :start]` | `system_time` | `template_name` |
| `[:sfera_doc, :render, :stop]` | `duration` | `template_name` |
| `[:sfera_doc, :render, :exception]` | `duration` | `template_name`, `error` |
<h1> Made with ❤️ by<span style="font-weight:700; color:#7C3AED; letter-spacing:0.5px;" > Sfera</span>
</h1>