Writing a blueprint
{% raw %} A blueprint is a directory of generation modules that Crabster stacks on top of its own. It changes what a generated project contains without a line of Crabster's core moving — that is the condition set by principle 7 of the vision, and the only way Crabster serves people whose needs nobody anticipated.
This page is enough to write one. If something you needed is missing here, that is a defect in this page.
At a glance🔗
crabster import-cdl model.cdl --blueprint ./my-blueprint
my-blueprint/
├── ci-gitlab/ ← a new module
│ ├── module.toml
│ ├── .gitlab-ci.yml.tera
│ └── _into/README.md/
│ └── sections.tera ← writes into a file `core` owns
└── core/ ← the `core` module, not a copy of it
├── module.toml
└── .github/workflows/
└── ci.yml.tera ← replaces that one template, and only it
That is the blueprint shipped with Crabster, in
examples/blueprints/gitlab/. It is
generated and checked in CI on every run: what this page describes is what
runs.
What a blueprint can do🔗
Four things, and nothing else. Resolution is template by template, never module by module.
| What you want | What you drop in |
|---|---|
| Replace a template | A file at the same path, in a directory of the same module name |
| Add a file to an existing module | A file with a name nobody used, in a directory of the same module name |
| Add a whole module | A directory with its module.toml |
| Write into a file another module owns | _into/<destination>/<slot>.tera |
A template that renders only whitespace writes no file. That is how you remove something: the example blueprint replaces the GitHub workflow with an empty template, and the generated project has none.
The destination is derived from the path by dropping .tera. It is declared
nowhere else, so it cannot drift.
The manifest🔗
A directory is a module only if it holds a module.toml.
name = "ci-gitlab"
description = "A GitLab pipeline instead of the GitHub one"
requires = ["core"]
templates = "0.23.0"
variables = ["project_name", "database", "rust_version"]
write_once = ["src/migration/m*_*.rs"]
[activation]
setting = "ci"
value = "gitlab"
[[reserves]]
name = "Pipeline"
reason = "the `ci-gitlab` module declares that type"
An unknown key fails the load. Deliberately: a typo in a field name would otherwise be a setting silently ignored.
write_once — files a database has already run🔗
A migration is applied to a database, and SeaORM decides applied-or-pending by the migration's name alone. Rewriting one under an unchanged name therefore changes what the project expects without changing the database, and says nothing about it: the failure arrives at the first request, far from the cause.
A file named here is never rewritten, never deleted, and not opened by
--force. When new templates would produce something different, crabster upgrade leaves the file alone and writes what it would have produced to
.crabster/incoming/, for you to turn into a new migration.
The built-in modules declare their own — record freezes
src/migration/m*_*.rs, auth-jwt freezes src/auth/migration.rs. Declare
yours if your module generates anything a database runs. The lists are merged
rather than replaced: overriding one template of a module cannot unfreeze that
module's migrations.
Note the second star.
m*.rswould also matchmod.rs, which is the list of migrations and has to keep growing.
templates — why it is required🔗
A blueprint writes against slots, variables and file names the built-in templates define, and those move: before 1.0 a minor version may change any of them. A blueprint written a version ago would then fail deep inside a template, on a slot that no longer exists, and the message would say nothing about the actual cause.
So Crabster refuses a blueprint module that declares none, and one that declares another — compared on major and minor; the patch level never changes a template. The built-in modules do not declare it: they ship in the same binary as the number they would be compared against.
The current version is the templates one crabster --version reports, and the
one every generated project's .crabster/project.toml records.
The variables you get🔗
The ones core declares are provided for every project:
| Variable | What it is |
|---|---|
project_name | The generated crate's name |
database | postgres, mysql or sqlite |
database_display | PostgreSQL, MySQL, SQLite |
database_url | The example connection string |
database_name | The database name, - replaced by _ |
server_port | The port the server listens on |
sea_orm_driver | The matching SeaORM feature |
timestamp_column | The column a Timestamp becomes on this database |
rust_version | The highest MSRV the generated modules ask for |
model is added when the project has a domain model: the view described by
view.rs, with records,
enums, migrations and the rest.
Do not branch a template on {{ database }} to produce SQL. Type
translation lives in the view, once, and a template that redoes it drifts the
day the view changes. Branching on it for something else — a service in a
Compose file, an image in a pipeline — is exactly right.
Slots🔗
A file has one owner. To add something to it, the owner opens a named insertion point:
{{ slot(name="dependencies") }}
and you contribute from a directory symmetrical to _each_record/:
my-blueprint/my-module/_into/Cargo.toml/dependencies.tera
my-blueprint/my-module/_into/README.md/sections.tera
The directory is the destination, the file name is the slot. A
contribution to a slot nobody opens, or to a file no active module generates,
fails generation and names the slots that exist — the counterpart of
deny_unknown_fields.
The slots the built-in templates open are listed in
templates/README.md.
Protected regions🔗
{{ region(name="…") }} opens a region that belongs to whoever owns the
generated project: what is written there survives regeneration byte for byte,
and is not even run through rustfmt. Put one between items or statements,
never inside an expression — that is where rustfmt is least predictable,
and the engine refuses to generate if formatting moved a marker.
What a blueprint cannot do🔗
A blueprint is third-party code. Three guards apply:
- Symbolic links are ignored. A blueprint cannot pull files off the machine that is generating.
- A destination that escapes the project is refused.
- A blueprint path that does not exist, or a directory holding no module, fails the command rather than quietly falling back to the built-in modules — otherwise a typo in the path would generate the built-in project and report success.
And one limit that is not a guard: a blueprint cannot add a CLI subcommand or change the CDL language. It changes what is generated, not what generates.
Distributing one🔗
A blueprint is a directory. Ship it as a git repository, or as a crate whose template directory you point at:
git clone https://example.com/my-blueprint
crabster import-cdl model.cdl --blueprint ./my-blueprint
--blueprint takes a path, not a crate name. Resolving one would mean
downloading and running third-party templates from crates.io at generation
time, and that is a supply-chain question deserving better than a side effect
of this phase. Cloning, or cargo add and then pointing at the path, puts the
same decision in your hands while making it visible.
Being found🔗
Publish the crate with the crabster-blueprint keyword on crates.io. That
is the discovery convention, and it needs nothing but itself. Then open a pull
request adding a row to the table below.
crabster blueprint new writes that manifest for you, because a convention a
document merely asks for is one that is followed half the time:
name = "crabster-blueprint-<yours>"
keywords = ["crabster-blueprint"]
include = ["<module>/**", "README.md", "lib.rs"]
[workspace]
Four lines worth knowing. The keyword is the only one that has to be
exactly that. include needs a line per module you add, or the package
publishes without its templates and nobody can tell until they try to use it.
[workspace] is empty on purpose: without it, a blueprint written inside
another project is adopted by that project's workspace and cargo then refuses
to run in either. And lib.rs is a stub, because a package must have a
target and nothing links against this one.
That empty table is enough when the host lists its members by name. It is
not enough when the host matches them with a glob — members = ["crates/*"]
and the like — because the glob matches a directory that is now a workspace
root of its own, and every cargo command in the host fails with multiple workspace roots found in the same workspace. crabster blueprint new warns
when it writes inside a workspace and says the remedy:
[workspace]
exclude = ["crates/my-blueprint"]
Or write the blueprint somewhere that is not inside another project, which is where one usually belongs.
That the round trip works — scaffold, package, unpack somewhere else, generate — is checked by this repository's own gate rather than assumed.
Known blueprints🔗
| Blueprint | What it does | Templates | How it ships | Maintained by |
|---|---|---|---|---|
| React | A React front end over the generated TypeScript client, with a sign-in screen under auth jwt | 0.23.0 | in the binary — ui react is enough | The Crabster project |
| Vue | The same screen in Vue, over the same client | 0.23.0 | in the binary — ui vue | The Crabster project |
| Angular | The same in Angular — standalone components and signals, with no HttpClient service over the client | 0.23.0 | in the binary — ui angular | The Crabster project |
| GitLab CI | A GitLab pipeline instead of the GitHub one | 0.23.0 | --blueprint | The Crabster project |
| Diesel | Diesel instead of SeaORM, on SQLite or PostgreSQL — the persistence layer replaced, everything above it inherited | 0.23.0 | --blueprint | The Crabster project |
Why there are two ways to ship. The first three only ever add a module,
claimed by a setting nothing else answers to: so they are compiled into the
binary and appear only when a model says ui …. The last two replace a
built-in module by name — record for Diesel, core for GitLab — which
changes what every project is made of: that gets asked for, with --blueprint,
rather than installed by default. The rule is held by a test rather than by this
paragraph.
(Yours here.)
The Templates column is the one to read first. A blueprint declares the
templates version it was written against, and Crabster refuses one that names a
version it does not ship. A row saying something other than the version you have
is a blueprint that will not load — which is the whole point of the column, and
why this table is checked against the blueprints themselves by a test rather
than kept by hand.
Official means something here. A blueprint in this table under "The Crabster project" is generated and built by this repository's own gate, on every run, and held to what the core is held to. One that is merely linked to is a community blueprint, and the column says which is which — see ADR-0003. {% endraw %}