phoenix_assets usage rules
View Sourcephoenix_assets is an opinionated, batteries-included asset runtime for Phoenix:
SvelteKit + Vite + Tailwind v4 + Storybook + ElectricSQL + Phoenix PubSub +
localization + Ash→TypeScript types, wired together. It supervises Vite and
Storybook, generates typed frontend contracts from your backend, links everything
into one asset graph, and validates the production manifest.
The golden path
Do not write a preset module. The full stack is the default (
PhoenixAssets.Presets.Svelte). Configure the app and name your declaration modules; that's it:# config/config.exs config :phoenix_assets, otp_app: :my_app, endpoint: MyAppWeb.Endpoint, router: MyAppWeb.Router config :phoenix_assets, :stack, shapes: MyApp.Assets.ElectricShapes, commands: MyApp.Assets.Commands, session: MyApp.Assets.Session, topics: MyApp.Assets.PubSubTopics, types: MyApp.Assets.TypesSupervise it. Add the children to your application tree:
children = [...] ++ PhoenixAssets.child_specs()In production this starts only the manifest server; in dev (when
config :phoenix_assets, :dev, enabled: true) it also supervises Vite, Storybook, and the generated-file watcher.Point
svelte-checkat the generated contracts. The Vite plugin resolves$phoenix/*at dev and build time, butsvelte-checkandtscdo not run through Vite, so they need the alias spelled out:// assets/svelte.config.js kit: { alias: { $phoenix: "src/lib/generated" } }Match
generated_dirif you moved it. Without this the app builds and the type-check fails, which is a confusing half-hour the first time.
Tuning the stack without a preset
Every knob below is config. Reach for a preset only when you are changing which integrations run or their order — not to adjust one of them.
# Storybook off the supervised dev tree (run it on demand via `mix storybook`)
config :phoenix_assets, :dev, storybook: [enabled: false] # or: [port: 6007]
# Pin your market locales instead of scanning priv/gettext
config :phoenix_assets, :stack,
locales: ["sv", "en", "no", "da"],
default_locale: "sv",
gettext_backend: MyAppWeb.GettextDeclaration modules (the backend contract)
Each is a small DSL. The declarations are metadata only — the actual server work (Ash queries, policies, tenancy) stays in your controllers.
defmodule MyApp.Assets.ElectricShapes do
use PhoenixAssets.Electric.Shapes
shape :portfolios, route: "/shapes/portfolios", type: "PortfolioRow"
# Route placeholders become required, typed keys on the generated factory;
# `params:` is optional documentation, validated at compile time to match.
shape :user_portfolios, route: "/shapes/users/:user_id/portfolios",
type: "PortfolioRow", params: [:user_id]
end
defmodule MyApp.Assets.Commands do
use PhoenixAssets.Commands.Definitions
# Reads are shapes; everything that changes state is a command. Declaring the
# error codes is the point: the generated client returns a discriminated
# result, so a call site cannot read the payload without handling failure.
command :publish_portfolio,
route: "/api/portfolios/:id/publish",
method: :post,
params: [id: :string],
body: [note: :string],
result: "PortfolioRow",
errors: [:already_published, :portfolio_not_found]
end
defmodule MyApp.Assets.Session do
use PhoenixAssets.Session.Fields
# Who is asking, declared once, so neither side re-derives it by hand.
route "/api/session"
field :user_id, :string
field :organization_id, :string
field :role, :string, values: ["owner", "admin", "member"]
field :platform_admin, :boolean
end
defmodule MyApp.Assets.PubSubTopics do
use PhoenixAssets.PubSub.Topics
topic :portfolio, pattern: "portfolio:{id}",
events: [updated: "PortfolioRow", deleted: %{id: :string}]
end
defmodule MyApp.Assets.Types do
use PhoenixAssets.Types.Schema
type "PortfolioRow", resource: MyApp.Portfolio, only: :public
endTypes the backend already declares as Elixir typespecs — a streaming protocol, a job-status union — need no DSL at all. Point at the module:
config :phoenix_assets, :stack,
typespecs: [
[source: MyApp.Stream.Part, output: "stream-part.ts", root_name: "StreamPart"]
]Generated contracts
mix phoenix_assets.gen writes typed TypeScript into assets/src/generated/:
routes.ts (endpoint helpers for /shapes/* and /api/* — page routes are
SvelteKit's, never generated), env.ts, electric.ts, commands.ts,
session.ts, pubsub.ts, locales.ts, types.ts. The frontend imports them through $phoenix/* virtual
modules ($phoenix/routes, $phoenix/electric, …) provided by the Vite plugin.
The locale contract is $phoenix/locales; $phoenix/localize remains an alias
for older hosts.
Rules to rely on:
- Generation is deterministic and content-gated (no write when output is
byte-identical).
mix phoenix_assets.gen --checkfails on drift — wire it intoassets.deployas a CI gate. - A command result is a value, never an exception.
runCommandresolves to{ ok: true, data }or{ ok: false, error, status }; a network failure and an error code this build does not know both degrade to"unknown_error"rather than escaping as an untyped string or a rejected promise. - Sensitive and non-public Ash fields are excluded from generated row types
automatically (
sensitive?: trueandpublic?: falsenever reach the client). A doctor check warns when an exposed field is also field-policy-gated. - Run
mix phoenix_assets.doctor(add--productionin CI) to validate config, routes, manifest presence, and freshness.
Frontend packages
@phoenix-assets/vite— the Vite plugin (phoenixAssets),$phoenix/*virtual modules, HMR bridge, PO loader, graph emitter. Add it tovite.config.js.@phoenix-assets/svelte— typed runtime helpers:createShapeStore,authHeaders/createShapeUrl(used by the generated$phoenix/electricclient),runCommand(used by the generated$phoenix/commandsclient), the event modifiers (debounce,throttle,once,stopPropagation,preventDefault,self),matchEvent,resolveLocale, andconfigureShapeAuthto point the shape clients at your app's token key.createShapeCollection(TanStack DB) lives behind the@phoenix-assets/svelte/collectionsubpath so the main barrel stays free of the optional@tanstack/*peers.@phoenix-assets/svelte/reporting— strict portable-report decoding, the closed LayerChart-backed compiler/components, evidence states, and accessible table twins. Pass only the renderer-neutral contract. Product code supplies semantic CSS tokens and domain chrome; it does not import LayerChart directly or persist renderer option bags.@phoenix-assets/doc-shell— the renderer-neutral documentation UI for thedoc-shell/v1artifact contract. Only needed if you render docs in-app; theme it through the--doc-*custom properties rather than app aliases.
Linting & formatting (host apps)
Use Biome for the frontend (the same linter phoenix_assets uses — no
ESLint/Prettier). The stack ships the shared config + the Tailwind linter as
@phoenix-assets/lint:
pnpm add -D @phoenix-assets/lint @biomejs/biome tailwindcss svelte
- Biome:
biome.json→{ "extends": ["@phoenix-assets/lint/biome.base.json"] }, then layer your app-specific excludes/overrides on top. The base sets Svelte-aware rules (a**/*.svelteoverride disablinguseConst,useImportType,noUnusedVariables,noUnusedImports— Biome false-positives on those in Svelte). - Tailwind v4 hygiene: add a
lint:twscript running the compiledphoenix-assets-lint-tailwindbinary the package ships (or invoke it ad hoc withpnpm exec phoenix-assets-lint-tailwind) — it flags arbitrary values with a standard equivalent (w-[180px]→w-45). Wire both into CI.
When you genuinely need to deviate
A preset changes which integrations run and in what order — adding one the
stack doesn't ship, or dropping one entirely. Write a module with
use PhoenixAssets.Preset, list integration/2 calls, and set it as
config :phoenix_assets, preset: MyApp.Assets.Stack. Start by copying
PhoenixAssets.Presets.Svelte. Ordering is resolved at compile time (a cycle or
missing hard dependency is a compile error).
If your preset is the default list with one option changed, it is config you want — see "Tuning the stack without a preset" above.
Don't
- Don't hand-write a preset just to use the standard stack — omit
:preset. Turning Storybook off or pinning a locale list is config, not a preset. - Don't generate page routes or hand-copy contract types — let the generators own
them and import from
$phoenix/*. - Don't put secrets in
config :phoenix_assets, :env, expose: [...]— only listed keys are emitted, but treat the allow-list as public.