Packages

A supervised, type-safe SvelteKit frontend, first-class inside Phoenix.

Current section

Files

Jump to
phoenix_assets usage-rules.md
Raw

usage-rules.md

# phoenix_assets usage rules
`phoenix_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:
```elixir
# 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.Types
```
- **Supervise it.** Add the children to your application tree:
```elixir
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-check` at the generated contracts.** The Vite plugin resolves
`$phoenix/*` at dev and build time, but `svelte-check` and `tsc` do not run
through Vite, so they need the alias spelled out:
```js
// assets/svelte.config.js
kit: { alias: { $phoenix: "src/lib/generated" } }
```
Match `generated_dir` if 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.
```elixir
# 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.Gettext
```
## Declaration 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.
```elixir
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
end
```
Types the backend already declares as Elixir typespecs — a streaming protocol,
a job-status union — need no DSL at all. Point at the module:
```elixir
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 --check` fails on drift — wire it into
`assets.deploy` as a CI gate.
- **A command result is a value, never an exception.** `runCommand` resolves 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 by default** from generated
row types. `only: :all` includes private fields and `expose:` can explicitly
include private or sensitive fields; review these overrides before publishing.
A doctor check warns when an exposed field is also field-policy-gated.
- Run `mix phoenix_assets.doctor` (add `--production` in CI) to validate config,
routes, manifest presence, and freshness.
## Sync backend ownership
The host selects Phoenix.Sync and Electric. Phoenix Assets has no production
Hex requirement on either package. For PostgreSQL 18.6, use the qualified
Phoenix.Sync revision documented in the README and Electric 1.8.1 (with the
host's explicit override when embedding). Preserve sandbox transaction isolation
in tests. The frontend peers require `@electric-sql/client` >=1.5.27 within 1.x,
`@tanstack/electric-db-collection` >=0.4.7 within 0.4.x, and
`@tanstack/svelte-db` >=0.3.7 within 0.3.x. TanStack remains optional outside the
`/collection` entry point. Artifact and schema contracts retain their versions.
## Frontend packages
- `@phoenix-assets/vite` — the Vite plugin (`phoenixAssets`), `$phoenix/*` virtual
modules, HMR bridge, PO loader, graph emitter. Add it to `vite.config.js`.
- `@phoenix-assets/svelte` — typed runtime helpers: `createShapeStore`,
`createShapeFetch`/`createShapeUrl` (used by the generated `$phoenix/electric`
client), `runCommand` (used by the generated `$phoenix/commands` client), the
event modifiers (`debounce`, `throttle`, `once`, `stopPropagation`,
`preventDefault`, `self`), `matchEvent`, `resolveLocale`, and `configureShapeAuth` to point the
shape clients at your app's token key. `createShapeCollection` (TanStack DB)
lives behind the `@phoenix-assets/svelte/collection` subpath 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 the
`doc-shell/v1` artifact 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`:
```bash
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 `**/*.svelte` override disabling `useConst`,
`useImportType`, `noUnusedVariables`, `noUnusedImports` — Biome false-positives
on those in Svelte).
- **Tailwind v4 hygiene:** add a `lint:tw` script running the compiled
`phoenix-assets-lint-tailwind` binary the package ships (or invoke it ad hoc with
`pnpm exec phoenix-assets-lint-tailwind`) — it flags arbitrary values with a
standard equivalent (`w-[180px]``w-45`). CSS imports resolve exact and
trailing-wildcard `kit.alias` entries plus SvelteKit's implicit `$lib` alias
(including a custom `kit.files.lib`). Bare packages resolve from the importing
stylesheet and host project, including under pnpm's strict dependency layout.
- **Svelte structure:** `phoenix-assets-lint-svelte` parses the selected
components. It accepts standard module+instance script composition by default.
A host may opt into `--single-script` and use repeated `--allow <glob>` values
when that narrower convention is part of the host's own architecture.
Wire the applicable commands 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.
## Upgrading the audited dependency baseline
Ash 3.33 requires an explicit string-length policy in the **host application's**
configuration. Set this before compiling dependencies:
```elixir
config :ash, default_string_length_count: :codepoints
```
Update the Hex and npm companion packages together, then regenerate contracts.
Generated Electric clients use `createShapeFetch` to refresh credentials on each
request. Configure process-wide auth only with application-wide defaults; SSR
request-specific tokens belong in per-request configuration.
Generated file paths must stay below `asset_root` and cannot traverse symlinks
inside it. The configured root itself may be a symlink. Failed generation now
raises from `Generated.stale?/1`; use `Generated.generate(ctx, check: true)` to
handle tagged errors explicitly.