stub

Generate project files from your own Liquid stubs. Stack-agnostic — the output is just text.

npm install -g @bitperfect-software/stub

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:

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

FieldMeaning
templatesrequiredManifest key → template. Each key becomes a command.
variablesInputs every command asks for.
computedValues every command derives.
switchesFlags every command accepts.
referenceHow {% reference %} formats a path.
projectA name for this manifest. Parsed, currently unused.

Template fields

FieldMeaning
pathrequiredWhere the file goes, relative to the project root. Rendered as Liquid.
descriptionShown in --help. Defaults to Create a new <key>.
variablesInputs only this template asks for.
computedValues only this template derives.
switchesFlags only this template accepts.
requiresOther 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:

FamilyFiltersExample
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:

FilterResultFilterResult
camelCasetwoWordspascalCaseTwoWords
capitalCaseTwo WordspascalSnakeCaseTwo_Words
constantCaseTWO_WORDSpathCasetwo/words
dotCasetwo.wordssentenceCaseTwo words
kebabCasetwo-wordssnakeCasetwo_words
noCasetwo wordstrainCaseTwo-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" %}
nothingfalls back to the raw path, like {% path %}error
one unnamed formatuses iterror — drop the as:
named formats, one called defaultuses defaultuses y, or errors if undeclared
named formats, none called defaulterror — names the ones you did declareuses 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.

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.

MessageWhat it means
Could not find a .stub directory in … or any parent directoryYou are outside a project that has one. stub guide, --help and --version still work.
File not found: …/templates.jsonThe .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 xA 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:1A Liquid syntax error in a body, with its file, line and column.
{% path %} expects a quoted template nameThe argument was a variable or was unquoted.
{% path "x" %} refers to a template that is not in the manifestTypo in a manifest key.
{% path %} is only available in a template bodyA tag was used in a path, computed.value or reference.
{% reference "x" %} needs a format nameNamed 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 formatTypo 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 flagA 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 validateCheck every manifest entry, path and cross-reference without rendering.
--dry-runPrint the files a run would write, and write none of them.
stub variables <template>List the inputs a command takes, as data.
stub docsPer-template Markdown generated from the manifest.
stub initScaffold a .stub directory from one of the shipped examples.
Settings overridesA configurable directory name and manifest filename.
Overwrite protectionRefuse by default, with --force, or show a diff first.
Conditional requiresSo a switch can remove a file, not only content inside one.