@augeo/smelt
v2.1.0
Published
Library + CLI for building Shopify themes from a colocated component tree
Readme
Smelt
Build Shopify themes from colocated components. Author your button/ directory
once (button.liquid, button.ts, button.css, button.test.ts,
button.schema.ts), and Smelt compiles it into the flat, namespaced files
Shopify expects.
Why
- Colocation. Every component lives in one directory. Liquid markup,
TypeScript behavior, CSS, tests, and schema are authored side-by-side instead
of scattered across
sections/,snippets/, andassets/. - Layering. Smelt ships with a baseline component library; your theme
shadows any file. Drop in a
button.cssto restyle without touching markup, TS, or schema. Same pattern as Nuxt Layers or Gatsby shadowing. - Typed schemas. Author
{% schema %}blocks in TypeScript with autocomplete derived from Shopify's authoritative JSON Schemas. The build executes the file and injects the result. - Private blocks for free. Nest a
blocks/directory under a section or block, and its children become private theme blocks, emitted with Shopify's_prefix and auto-merged into the parent's schema. No manual filename mangling. - Additive build. Only files prefixed
built--(or_built--for private blocks) are written or cleaned. Hand-written theme files are preserved, so you can adopt incrementally.
Contents
Example
A consumer theme writes one directory per component:
src/
├── sections/
│ └── hero/
│ ├── hero.liquid
│ ├── hero.css
│ └── hero.schema.ts
└── components/
└── card/
├── card.liquid
└── card.csssrc/sections/hero/hero.liquid:
<section class="tr-hero">
<h2>{{ section.settings.heading }}</h2>
{% render '@/components/card', title: 'Hello', body: 'World' %}
</section>src/sections/hero/hero.schema.ts:
import { defineSchemaSection } from "@augeo/smelt/schema";
export const schema = defineSchemaSection({
name: "Hero",
settings: [
{ type: "text", id: "heading", label: "Heading", default: "Welcome" },
],
presets: [{ name: "Hero" }],
});Compile:
smelt buildOutputs sections/built--sections--hero.liquid,
snippets/built--components--card.liquid, etc. They're flat and namespaced, the
way Shopify wants.
See the 📄 example/ directory for a working consumer theme.
Install
npm install -D @augeo/smeltThis installs the smelt CLI and the @augeo/smelt/schema import for schema
authoring.
Authoring
Each component is a directory under src/<type>/<name>/ where <type> is
sections, blocks, or components. Files inside share the directory name:
| File | Role |
| ------------------ | -------------------------------------------- |
| <name>.liquid | Markup (required; anchors the component) |
| <name>.ts | Bundled and injected into {% javascript %} |
| <name>.css | Injected into {% stylesheet %} |
| <name>.schema.ts | Typed schema (sections + blocks only) |
| <name>.test.ts | Colocated test file, picked up by vitest |
Components reference other components with the @/ alias:
{% render '@/components/button', label: 'Continue' %}@/ resolves through the merged layer tree, so a consumer's button.liquid
automatically shadows the baseline.
Schemas
Author schemas in TypeScript with full type inference:
import { defineSchemaSection } from "@augeo/smelt/schema";
export const schema = defineSchemaSection({
name: "Hero",
settings: [
{ type: "text", id: "heading", label: "Heading" },
{
type: "select",
id: "alignment",
label: "Alignment",
options: [
{ value: "left", label: "Left" },
{ value: "center", label: "Center" },
],
default: "center",
},
],
});Types are codegen'd from Shopify's authoritative schemas in
theme-liquid-docs. Update via
npm run docs:update.
Inline {% schema %} blocks in .liquid files are a build error: a single
source of truth.
Private blocks
Shopify treats theme block files prefixed with _ as private: hidden from
the merchant's block picker, renderable only via a parent's
{% content_for "blocks" %}. Smelt expresses this structurally: a blocks/
directory nested under a section or block emits its children with the _ prefix
automatically.
src/sections/hero/
├── hero.liquid
├── hero.schema.ts
└── blocks/
└── feature/
├── feature.liquid
└── feature.schema.tsCompiles to:
sections/built--sections--hero.liquid
blocks/_built--sections--hero--blocks--feature.liquidThe parent's schema auto-merges discovered children into its blocks: [] array,
sorted by directory name and prepended; any explicit entries you list (e.g.
globally-shared block types) appended after. Nesting is recursive: a private
block can have its own blocks/ subdir.
Top-level src/blocks/* files remain public (no _ prefix).
Block faces
A src/components/* component compiles to a snippet — reusable through
{% render %}, but invisible to the theme editor. A block face also exposes
that same component as a theme block a merchant can drop onto a page, without
duplicating it. Declare one with a singular block/ directory inside the
component:
src/components/card/
├── card.liquid # the reusable snippet
├── card.css
└── block/
└── card.schema.ts # the block face's schemaCompiles to both:
snippets/built--components--card.liquid # the snippet, unchanged
blocks/built--components--card.liquid # the block faceWith just a schema (no block/card.liquid), the face is mechanical: the
build synthesizes the wrapper for you — rendering the component, mapping each
setting to a render arg of the same name, and passing shopify_attributes
through. So name your component's props to match the setting ids.
{% # blocks/built--components--card.liquid (generated) %}
{% render 'built--components--card',
title: block.settings.title,
body: block.settings.body,
shopify_attributes: block.shopify_attributes
%}
{% schema %}…{% endschema %}When the wrapper isn't mechanical — derived props, conditional logic, or passing
children: block.blocks — add a block/card.liquid and the build uses it as
the body verbatim (renders rewritten, schema injected). The face is then just a
normal block component living in block/, so it can carry its own
block/card.ts / block/card.css too.
Faces are components-only: sections and blocks don't get them. A block face
can own private child blocks by nesting a blocks/ directory inside block/ —
see docs/build-spec.md for that and the full rules.
Layering
Smelt walks an ordered list of layers and merges them per-file. The default is:
- Consumer: your theme's
src/(process.cwd()). @augeo/smelt: the package's baselinesrc/.
For each component slot (liquid, ts, css, schema), the first layer that
has the file wins. Drop just a button.css in your consumer to override styles;
the baseline's button.liquid and button.ts are inherited.
Build
smelt buildRun from the theme root. Outputs go to sections/built--*.liquid,
blocks/built--*.liquid, and snippets/built--*.liquid, prefixed so they
coexist with hand-written files in the same directories.
Watch mode
smelt devRuns an initial build, then watches each layer's src/ and rebuilds on file
changes (add, edit, delete). Build failures log and keep the watcher alive. Pair
with shopify theme dev (in another terminal or via
concurrently): smelt dev
writes the built files; shopify theme dev uploads them.
Committing the output
Shopify imports themes from git, so built--* files (and _built--* for
private blocks) need to be committed alongside source. Two configs keep the
noise down:
.gitattributes marks output as generated so GitHub collapses it in PR diffs
and excludes it from language stats:
sections/built--*.liquid linguist-generated=true
snippets/built--*.liquid linguist-generated=true
blocks/built--*.liquid linguist-generated=true
blocks/_built--*.liquid linguist-generated=true.prettierignore skips the output so Prettier doesn't reformat it between
builds:
sections/built--*.liquid
snippets/built--*.liquid
blocks/built--*.liquid
blocks/_built--*.liquidCI: verify the build is committed
Because the built--* files are committed, they can drift from source: someone
edits a component but forgets to rebuild, or commits a stale build. Catch it in
CI by rebuilding and failing if the working tree is dirty: a clean tree means
the committed output already matches source.
.github/workflows/verify.yml:
name: Verify
on:
push:
branches: [main]
pull_request:
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version-file: "package.json"
cache: "npm"
- run: npm ci
- name: Build
run: npm run build # → smelt build
- name: Check built files match source
run: |
if [ -n "$(git status --porcelain)" ]; then
echo "::error::Build produced uncommitted changes. Did you forget to commit a rebuild?"
git status --porcelain
git diff
exit 1
fiThe build is additive and deterministic: it only writes built--*
files, so a rebuild on a current tree produces no diff. Any change to
git status means the commit is missing a rebuild.
If you run other checks (tests, shopify theme check), put the build step
first so the job fails fast when the committed output is stale. There's no
point linting and testing a tree you already know is out of date. See this
repo's own verify.yml for the full pattern,
including git submodules and a Playwright browser for the test suite.
Learn More
- 📄 Build Spec: pipeline, alias rules, conventions
- 📄 Testing Conventions: how to write tests against the built output
@augeo/assay: the test runner Smelt uses
Future Plans
smelt.config.ts: consumer-defined layer list, enabling N-layer composition (e.g., a community component pack between consumer and baseline).- More baseline components. Currently just
buttondemo. Expanding to a real default set.
