What it is
stub reads a .stub directory from your project and turns every entry in its
manifest into a subcommand. Running one renders that entry's Liquid body — and, optionally, a whole
chain of related entries — into files in your project.
The output is just text: TSX, PHP, C#, CSS, SQL, Terraform, anything. stub has no
opinion about it. It is built for stubs too large to keep as editor snippets: one
command that emits a page plus its hooks, its form, its types and its API layer. For a single file with
two substitutions, an editor snippet is the better tool.
A stub is parameterised by three things:
- variables — required inputs the user passes positionally.
- computed — values derived from those variables by convention, overridable with a CLI option.
- switches — boolean flags that include or exclude parts of the output.
Setting up
Create a .stub directory in your project root. stub walks up from the
current directory until it finds one, so you can run it from anywhere inside the project.
.stub/
templates.json the manifest — the catalogue of what can be generated
component.liquid the body of the "component" entry
componentTest.liquid the body of the "componentTest" entry
A template's body file is its manifest key plus .liquid. There is no
field pointing at a filename; the key is the filename.
A .liquid file that no manifest entry names is not a command and is never written on its
own. It can still be pulled into another body as a partial with Liquid's {% render %},
which is how a shared banner is done.
Bodies are standard LiquidJS templates. Every LiquidJS tag and
filter works, plus the ones stub adds.
The manifest
templates.json is the catalogue. Its top level declares the inputs and conventions
shared by everything; templates lists the entries themselves. This is the
examples/module manifest, which exercises every feature on this page:
{
"project": "stub module example",
"variables": [{ "name": "entity", "description": "The record type to generate, e.g. Invoice" }],
"computed": [
{ "name": "entityPlural", "description": "Plural form of the entity", "value": "{{ entity | plural }}" },
{ "name": "dir", "description": "Directory the generated files go in", "value": "src/{{ entityPlural | kebabCase }}" }
],
"switches": [{ "name": "noDelete", "description": "Omits the remove function from the store" }],
"reference": {
"default": "@/{{ targetPath | remove_first: 'src/' | replace_last: '.ts', '' }}",
"relative": "./{{ targetPath | split: '/' | last | replace_last: '.ts', '' }}"
},
"templates": {
"model": {
"description": "Creates a record type, its store and its test",
"path": "{{ dir }}/{{ entity | pascalCase }}.ts",
"requires": ["store", "modelTest"]
},
"store": {
"description": "Creates an in-memory store for a record type",
"path": "{{ dir }}/{{ entity | camelCase }}Store.ts"
},
"modelTest": {
"description": "Creates a test for a record type",
"path": "{{ dir }}/{{ entity | pascalCase }}.test.ts",
"computed": [
{ "name": "dir", "description": "Tests live in their own tree", "value": "tests/{{ entityPlural | kebabCase }}" }
]
}
}
}
That manifest gives you three commands — model, store and
model-test.
Top-level fields
| Field | Meaning | |
|---|---|---|
templates | required | Manifest key → template. Each key becomes a command. |
variables | Inputs every command asks for. | |
computed | Values every command derives. | |
switches | Flags every command accepts. | |
reference | How {% reference %} formats a path. | |
project | A name for this manifest. Parsed, currently unused. |
Template fields
| Field | Meaning | |
|---|---|---|
path | required | Where the file goes, relative to the project root. Rendered as Liquid. |
description | Shown in --help. Defaults to Create a new <key>. | |
variables | Inputs only this template asks for. | |
computed | Values only this template derives. | |
switches | Flags only this template accepts. | |
requires | Other manifest keys to render in the same run. |
Every array field is optional and defaults to []. Unknown fields are ignored rather than
rejected.
Inputs
variables, computed and switches all take name and
description; computed additionally takes value.
variables
Required positional arguments, in declaration order.
{ "name": "entity", "description": "The record type to generate" }
$ stub model Invoice
computed
Optional overrides. value is a Liquid expression evaluated against the input, and
passing the option skips the expression entirely.
{ "name": "entityPlural", "description": "Plural form", "value": "{{ entity | plural }}" }
$ stub model Person # entityPlural is "People"
$ stub model Person --entityPlural Leute # the expression never runs
switches
Boolean flags. Inside a body a switch is simply truthy.
{ "name": "noDelete", "description": "Omits the remove function" }
$ stub model Invoice --noDelete
{%- unless noDelete %} … {%- endunless %}
A switch removes content, not files. It can drop a block inside a body, but it
cannot drop an entry from the run — a template whose whole body is switched off still writes an empty
file. Conditional requires is planned.
Merging and order
A command's inputs are the manifest globals, plus the template's own declarations, plus those of
everything it requires, deduplicated by name. The first declaration of a name
wins, so a global beats a template-local one for what the command exposes.
At render time each template derives the globals plus its own computed
— and there the later declaration wins, so a template-local value shadows a global of the same name in
its own output. Above, modelTest redeclares dir, so stub model
Invoice writes:
src/invoices/Invoice.ts
src/invoices/invoiceStore.ts
tests/invoices/Invoice.test.ts <- modelTest's own dir
That asymmetry is deliberate: every template derives its scope from the same raw input, never from a
parent's scope, so one override reaches the whole chain. It also means passing --dir on the
command line short-circuits derivation everywhere, collapsing the shadowing — an override is a value,
not an expression. Values are derived left to right, so a later expression can build on an earlier
one.
Naming
Command names are kebab-cased from the manifest key: useColumnsHook
becomes stub use-columns-hook. Option names are used exactly as declared:
--entityPlural, --noDelete.
requires
"requires": ["store", "modelTest"] renders those entries in the same run, from the same
input. It is transitive, each entry is rendered once, and cycles
terminate safely. A command exposes the options of everything it requires, so a single override reaches
the whole chain.
$ stub --noRequires model Invoice # renders only the model
--noRequires is a program option, so it goes before the command
name.
Filters
On top of every standard LiquidJS filter:
| Family | Filters | Example |
|---|---|---|
| Case | camelCase capitalCase constantCase dotCase
kebabCase noCase pascalCase pascalSnakeCase
pathCase sentenceCase snakeCase trainCase |
{{ "two words" | pascalCase }} → TwoWords |
| Number | plural singular (English only) |
{{ "person" | plural }} → people |
| Fallback | override |
{{ entityPlural | override: custom }} → custom if set, else entityPlural |
All twelve case filters, applied to two words:
| Filter | Result | Filter | Result |
|---|---|---|---|
camelCase | twoWords | pascalCase | TwoWords |
capitalCase | Two Words | pascalSnakeCase | Two_Words |
constantCase | TWO_WORDS | pathCase | two/words |
dotCase | two.words | sentenceCase | Two words |
kebabCase | two-words | snakeCase | two_words |
noCase | two words | trainCase | Two-Words |
They accept twoWords as readily as two words; the input is split on case
boundaries first.
Referring to another template
A stub rarely stands alone: a store imports its model, a test imports both. Each of those files is
its own manifest entry with its own path, so without help every body ends up retyping a
sibling's path — and the moment that entry's path changes, every hand-written copy silently
points at nothing. Two tags let a body ask the manifest instead.
{% path %}
{% path "model" %} emits the target file project-root-relative —
byte-for-byte the path this run writes that entry to:
src/invoices/Invoice.ts
{% reference %}
{% reference "model" %} @/invoices/Invoice
{% reference "model" as: "relative" %} ./Invoice
The same target, run through a reference format — how you bridge "the file lives at
src/invoices/…" and "the import reads @/invoices/…". The argument to both tags
is the manifest key of the entry you are pointing at, not a filename.
Reference formats
Add a top-level reference field. It is a Liquid expression in which
targetPath is the target entry's rendered path.
{ "reference": "@/{{ targetPath | remove_first: 'src/' }}" }
If one is not enough — imports use an alias, links need a URL — use named formats:
{
"reference": {
"default": "@/{{ targetPath | remove_first: 'src/' | replace_last: '.ts', '' }}",
"relative": "./{{ targetPath | split: '/' | last | replace_last: '.ts', '' }}"
}
}
A format is rendered with the target's own scope — its variables and its
computed, so {{ entity }} inside a format means the target's entity —
plus targetPath.
The rules
reference declares | {% reference "x" %} | {% reference "x" as: "y" %} |
|---|---|---|
| nothing | falls back to the raw path, like {% path %} | error |
| one unnamed format | uses it | error — drop the as: |
named formats, one called default | uses default | uses y, or errors if undeclared |
named formats, none called default | error — names the ones you did declare | uses y, or errors if undeclared |
So you can start with no reference field at all and add one when you need it.
Good to know
stub overwrites existing files without asking. Running a command twice destroys
hand-edits, with no warning and no --force. Generate into a clean tree, or commit before
you regenerate.
- Nothing is written unless everything renders. A run computes every target path and every file body before it touches disk, so a failure anywhere leaves the tree exactly as it was.
- The extension is emitted verbatim.
{% reference "model" %}ends in.tsif the entry'spathdoes. Trim it inside the format. - A tag's output cannot be piped. To pipe a raw path, capture it first:
{% capture target %}{% path "model" %}{% endcapture %}. - Both tags take a quoted literal only.
{% path model %}is rejected at parse time — which means every reference is checkable without running anything. - Both are independent of
requires. They resolve any entry in the manifest, including one this invocation does not write, the current template itself, and an entry that points back at this one. Nothing loops. - The tags work in template bodies only — not inside a manifest
path,computed.valueorreferenceexpression. That restriction is what keeps them non-recursive. - They work inside a
{% render %}partial, even though{% render %}gives the partial an isolated scope that sees none of your variables. - Tags and variables do not collide.
{% path "x" %}is the tag;{{ path }}is still your own variable calledpath. - Wrap literal braces Liquid must not touch in
{% raw %} … {% endraw %}.
Errors
Every message below is a problem with the project, not with stub, and is printed without
a stack trace. Set STUB_DEBUG=1 to get the stack anyway. Anything reported as
"This is a defect in stub" is worth
an issue.
| Message | What it means |
|---|---|
Could not find a .stub directory in … or any parent directory | You are outside a project that has one. stub guide, --help and --version still work. |
File not found: …/templates.json | The .stub directory exists but has no manifest. |
Invalid JSON in file: … | templates.json does not parse. |
Invalid manifest … | It parses but does not match the schema. Zod's field-by-field output follows. |
Unknown template x | A requires entry, or a command, names a key the manifest does not declare. |
ENOENT: Failed to lookup "x" in "…" | The manifest declares x but .stub/x.liquid is missing. |
tag {% if x %} not closed, line:1, col:1 | A Liquid syntax error in a body, with its file, line and column. |
{% path %} expects a quoted template name | The argument was a variable or was unquoted. |
{% path "x" %} refers to a template that is not in the manifest | Typo in a manifest key. |
{% path %} is only available in a template body | A tag was used in a path, computed.value or reference. |
{% reference "x" %} needs a format name | Named formats, none called default. Add as:. |
… declares a single unnamed format, so drop the "as:" | as: was passed where there is nothing to pick between. |
… is not a declared reference format | Typo in the format name; the message lists the ones that exist. |
cannot add command 'x' as already have command 'x' | Two manifest keys kebab-case to the same command name. |
Cannot add option '--x' … due to conflicting flag | A computed and a switch share a name. |
A broken manifest does not take stub guide, stub --help or
stub --version down with it — the help output carries the reason as a footer instead. Those
are the commands you run because the project is broken.
Not yet implemented
Planned, and deliberately not in 0.1.0. Nothing on this page or in stub --help promises
them.
stub validate | Check every manifest entry, path and cross-reference without rendering. |
--dry-run | Print the files a run would write, and write none of them. |
stub variables <template> | List the inputs a command takes, as data. |
stub docs | Per-template Markdown generated from the manifest. |
stub init | Scaffold a .stub directory from one of the shipped examples. |
| Settings overrides | A configurable directory name and manifest filename. |
| Overwrite protection | Refuse by default, with --force, or show a diff first. |
Conditional requires | So a switch can remove a file, not only content inside one. |