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` connects Phoenix to a SvelteKit frontend built with Vite,
Tailwind v4, Storybook, ElectricSQL, Phoenix PubSub, localization, and generated
Ash TypeScript types. It supervises the development processes, generates the
frontend contracts, records them in an asset graph, and validates the production
manifest.
## The golden path
- Use the default preset. The full stack uses
`PhoenixAssets.Presets.Svelte`. Configure the app and name your declaration
modules:
```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
```
- Add the children to the application supervision 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 it has been moved. Without this alias, Vite can build
while the separate type check fails.
## Tuning the stack without a preset
Use configuration to tune an existing integration. Write a preset only when the
set or order of integrations changes.
```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 declaration module is a small DSL for metadata. Ash queries, policies,
tenancy, and other server behavior stay in the host application.
```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 already declared as Elixir typespecs need no additional DSL. Point at the
module directly:
```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 remain
owned by SvelteKit), `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; byte-identical output is not
rewritten. `mix phoenix_assets.gen --check` fails on drift. Add it to
`assets.deploy` as a CI gate.
- A command result is a value. `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 including `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 and formatting in host applications
Use Biome for the frontend. Phoenix Assets uses the same linter rather than
ESLint and Prettier. The shared config and Tailwind linter ship in
`@phoenix-assets/lint`:
```bash
pnpm add -D @phoenix-assets/lint @biomejs/biome tailwindcss svelte
```
- Biome: add `{ "extends": ["@phoenix-assets/lint/biome.base.json"] }` to `biome.json`,
then layer your app-specific excludes/overrides on top. The base sets
Svelte-aware rules (a `**/*.svelte` override disabling `useConst`,
`useImportType`, `noUnusedVariables`, and `noUnusedImports` because Biome
reports false positives on those rules in Svelte files).
- Tailwind v4: add a `lint:tw` script that runs 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]` becomes `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.
## Custom presets
A preset changes which integrations run and in what order, either by adding an
integration the stack does not 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 the default list needs only an option change, use configuration as described
in "Tuning the stack without a preset".
## Boundaries
- Omit `:preset` when using the standard stack.
Turning Storybook off or pinning a locale list is config, not a preset.
- Keep page routes in SvelteKit and let the generators own contract types.
Import generated types from `$phoenix/*`.
- Do not 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.