Packages

Scaffolder for Elixir/Phoenix projects with a Svelte 5 UI layer.

Current section

Files

Jump to
svelixir README.md
Raw

README.md

# Svelixir
Scaffolder for Elixir/Phoenix projects with a Svelte 5 UI layer.
Svelixir generates a project and then keeps it upgradable. `svelixir.exs` records
what the project asked for; `fireside.exs` records what was actually written, and
with which content hashes, so a later regeneration can tell an untouched file
from one the developer has edited. Every reader here works against an explicit
project root rather than the current working directory — `Svelixir.Target`
exists for exactly that reason, and `Svelixir.BoundaryGuard` enforces it
mechanically over `lib/`.
## The two perimeters
The work is split across two repositories, and the split is structural rather
than organisational.
| perimeter | repository | what it is |
| --------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 — the package | `wimwian-org/svelixir` (this one) | the library a generated project depends on: `Svelixir.Target`, `Svelixir.Config`, `Svelixir.Manifest`, `Svelixir.Exs`, `Svelixir.Hash`, `Svelixir.Baseline` |
| 2 — the archive | `wimwian-org/svelixir_new` | the `mix svelixir.new` Mix archive, checked out at `priv/svelixir_new` |
Perimeter 2 is its own project because a Mix archive has to ship with **zero hex
requirements**: it is unpacked straight onto the developer's code path, where a
dependency of its own would be unresolvable. A separate Mix project is what lets
`mix archive.build` produce that, and it is also what keeps the two code paths
disjoint — neither perimeter's ebin is ever on the other's, so a change to one
cannot quietly compile against the other.
The two meet exactly once: the archive writes a `svelixir.exs` and this package
reads it back with `Svelixir.Config.read!/1`. There is no shared encoder and no
digest pinned in two files.
`priv/svelixir_new` is gitignored here. Clone it alongside before running the
cross-perimeter tests:
```sh
git clone git@github.com:wimwian-org/svelixir_new.git priv/svelixir_new
```
Everything degrades visibly without it rather than silently: `test/test_helper.exs`
excludes the `:archive` tag when `priv/svelixir_new/mix.exs` is absent,
`bin/check` skips that perimeter's gates, and CI annotates the run with a
`::warning` naming what did not run.
## Running the checks
`bin/check` is the commit gate — format, `credo --strict`, coveralls at
`minimum_coverage: 100`, doctor and dialyzer, in both perimeters:
```sh
bin/check
```
It does **not** run the tagged tests, and a green `bin/check` alone is therefore
not a green suite. Three tags are excluded by default, and they are disjoint **by
dependency**: no test carries more than one, because ExUnit's include filter
beats its exclude filter per tag, so a doubly-tagged test would run in an
environment that cannot support it.
| tag | what it needs | how it runs |
| -------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------- |
| `:toolchain` | the external toolchain only (`mix new`, `phx_new`) — safe anywhere | `mix test --include toolchain` |
| `:determinism` | a subprocess probe re-run under a reversed atom-interning order | `mix test --include determinism` |
| `:archive` | `priv/svelixir_new`, to build and install the archive | runs in a plain `mix test` when the perimeter is present; excluded when it is not |
The full local sequence:
```sh
bin/check
mix test --include toolchain --include determinism
(cd priv/svelixir_new && mix test --include toolchain)
```
`Svelixir.TagCoverageTest` asserts mechanically that every tag the suite excludes
is **named** by an `--include` on an **unguarded** CI step, so a newly excluded
tag cannot go unnamed by every workflow step. It reads `ci.yml` as text, so what
it proves is that a step exists which would run the tag — never that the step
ran. `:archive` is exempted from the unguarded half by name: its CI step is
guarded on the perimeter 2 checkout, which reads a private repository through the
`SVELIXIR_NEW_TOKEN` secret and 404s whenever that secret is absent, expired or
withheld — so `:archive` coverage in CI is contingent on a credential, and the
`::warning` in the run summary is what makes its absence visible. That failure
mode is the one this project calls F4: tests that silently did not run.
## The hex dependency source
`mix svelixir.new` splices `{:svelixir, "~> 0.1", only: [:dev], runtime: false}`
into the generated project by default, and accepts `--svelixir-path` to splice a
**path** dependency instead. The default resolves: the package is published, and
`~> 0.1` admits every 0.x release.
Both end-to-end tests still pass `--svelixir-path`, so the `{:hex, requirement}`
branch of the generator's `dep_line/1` stays uncovered end to end. That is
deliberate rather than an oversight: the honest test needs the network, and as an
untagged test it would turn `bin/check` red for any offline developer, for reasons
unrelated to their change. What publishing changed is the failure mode — the
branch is no longer guaranteed to fail, so it is untested rather than known-broken.
## Installation
```elixir
def deps do
[
{:svelixir, "~> 0.1", only: [:dev], runtime: false}
]
end
```
`only: [:dev]` and `runtime: false` are deliberate rather than incidental:
Svelixir is a build-time scaffolder, and nothing it defines is needed once the
generated application is running.
To work against an unreleased checkout, depend on it by path instead — this is
what `--svelixir-path` generates:
```elixir
{:svelixir, path: "../svelixir", only: [:dev], runtime: false}
```
Documentation is generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
and published at <https://hexdocs.pm/svelixir>. The vendored libraries in
`vendor/` compile into this application but are filtered out of the docs by
`filter_modules` — their API is not ours to document.
## Acknowledgements
### Prior art
Svelixir's central idea — that a generated project should stay **upgradable**,
which means telling a file the generator wrote from a file a human has since
edited — is not original to it.
- **[Fireside](https://github.com/ibarakaiev/fireside)** by Ihor Barakaiev (MIT)
is where `fireside.exs` gets both its name and its shape. Fireside imports code
components into an _existing_ project together with their dependencies and can
upgrade them later, which is the same problem this project solves for a whole
scaffold. The `svelixir.exs` / `fireside.exs` split — intent in one file, what
was actually written in the other — is Fireside's insight applied to a
generator. The name is kept deliberately rather than rebranded: it should be
obvious where the idea came from.
- **[Igniter](https://github.com/ash-project/igniter)** by Zach Daniel and the
Ash team (MIT) is the code-generation and project-patching framework Fireside
is built on, and the reference for what patching an existing project well looks
like — composable tasks over a parsed project rather than string surgery.
Svelixir does not depend on Igniter; the structural-placement modules
(`Svelixir.MixExs`, `Svelixir.ConfigExs`, `Svelixir.ApplicationEx`) solve a
deliberately narrower problem against a vendored Sourceror. Igniter is the
right tool for the general case.
- **[Sourceror](https://github.com/doorgan/sourceror)** by doorgan (Apache-2.0)
is what makes structural placement possible at all, and it is vendored here
(see below). `get_range/1` plus `patch_string/2` edits a range of
an existing file and leaves every other byte untouched — as opposed to
reprinting the AST, which reformats code the user never asked to change. Every
`mix.exs`, `config/*.exs` and `application.ex` edit this project makes goes
through it.
Fireside depends on Igniter, and Igniter depends on Sourceror. Svelixir sits at
the end of that chain and owes all three.
### Vendored libraries
Four libraries are compiled directly into this application from `vendor/` rather
than resolved as Hex dependencies, so that the generated archive perimeter can
carry zero Hex requirements. Each directory holds the upstream `lib/` tree and
the upstream licence file, unmodified except for two recorded one-line patches.
Full provenance, checksums and patch notes are in
[`vendor/README.md`](vendor/README.md).
| Library | Version | Licence | Author | Used for |
| ----------------------------------------------------------- | ------- | ---------- | ------------------------------------- | ----------------------------------------------- |
| [`sourceror`](https://github.com/doorgan/sourceror) | 1.12.2 | Apache-2.0 | doorgan | structural placement — parse and patch by range |
| [`vex`](https://github.com/CargoSense/vex) | 0.9.2 | MIT | Bruce Williams | validating `svelixir.exs` sections |
| [`typedstruct`](https://github.com/saleyn/typedstruct) | 0.5.4 | MIT | Jean-Philippe Cugnet and contributors | the config and manifest structs |
| [`simple_enum`](https://github.com/ImNotAVirus/simple_enum) | 1.0.0 | MIT | DarkyZ aka NotAVirus | enumerated section values |
These are other people's work carrying other people's licences. Nothing in
`vendor/` is formatted, linted or documented by this project's gates, and the
vendored modules are filtered out of the published docs — their API is theirs,
not ours.
### Generated projects
Generated projects get their UI components from
[sv5ui](https://sv5ui.vercel.app), a skin over the
[bits-ui](https://bits-ui.com) headless components, with the
[sveltic](https://sv5ui.vercel.app/templates) theme template. The project
skeleton itself comes from [Phoenix](https://www.phoenixframework.org)'s own
`phx.new`, which Svelixir composes on top of rather than replaces.