crabster

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 wantWhat you drop in
Replace a templateA file at the same path, in a directory of the same module name
Add a file to an existing moduleA file with a name nobody used, in a directory of the same module name
Add a whole moduleA 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*.rs would also match mod.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:

VariableWhat it is
project_nameThe generated crate's name
databasepostgres, mysql or sqlite
database_displayPostgreSQL, MySQL, SQLite
database_urlThe example connection string
database_nameThe database name, - replaced by _
server_portThe port the server listens on
sea_orm_driverThe matching SeaORM feature
timestamp_columnThe column a Timestamp becomes on this database
rust_versionThe 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:

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🔗

BlueprintWhat it doesTemplatesHow it shipsMaintained by
ReactA React front end over the generated TypeScript client, with a sign-in screen under auth jwt0.23.0in the binary — ui react is enoughThe Crabster project
VueThe same screen in Vue, over the same client0.23.0in the binary — ui vueThe Crabster project
AngularThe same in Angular — standalone components and signals, with no HttpClient service over the client0.23.0in the binary — ui angularThe Crabster project
GitLab CIA GitLab pipeline instead of the GitHub one0.23.0--blueprintThe Crabster project
DieselDiesel instead of SeaORM, on SQLite or PostgreSQL — the persistence layer replaced, everything above it inherited0.23.0--blueprintThe 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 %}