@augeo/smelt 1.2.3 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,6 +32,29 @@ jobs:
32
32
  working-directory: example
33
33
  run: npm ci
34
34
 
35
+ # The root build is a prerequisite: it produces the `smelt` bin
36
+ # (dist/cli.mjs, gitignored) that the example's `smelt build` needs.
37
+ - name: Build smelt bin (prerequisite for the example build)
38
+ run: npm run build
39
+
40
+ # Build first so the job fails fast when the committed `built--*` output is
41
+ # stale, before spending time on lint, typecheck, and the full test run.
42
+ # Only the example build writes tracked files, so it's what the drift
43
+ # check below actually guards.
44
+ - name: Build example (regenerates tracked built--* output)
45
+ working-directory: example
46
+ run: npm run build
47
+
48
+ - name: Check built files match source
49
+ run: |
50
+ if [ -n "$(git status --porcelain)" ]; then
51
+ echo "::error::Build produced uncommitted changes. Did you forget to commit a rebuild?"
52
+ git status --porcelain
53
+ echo "---"
54
+ git diff
55
+ exit 1
56
+ fi
57
+
35
58
  - name: Restore prettier cache
36
59
  uses: actions/cache@v4
37
60
  with:
@@ -52,13 +75,3 @@ jobs:
52
75
 
53
76
  - name: Run tests (integration via example)
54
77
  run: npm run test:all
55
-
56
- - name: Check built files match source
57
- run: |
58
- if [ -n "$(git status --porcelain)" ]; then
59
- echo "::error::Build produced uncommitted changes — did you forget to commit a rebuild?"
60
- git status --porcelain
61
- echo "---"
62
- git diff
63
- exit 1
64
- fi
package/AGENTS.md CHANGED
@@ -25,9 +25,7 @@ The repo currently has two halves:
25
25
  integration test bed (same pattern as
26
26
  [`@augeo/assay`](https://github.com/seanhealy/assay)'s `example/`).
27
27
 
28
- See [`docs/build-spec.md`](./docs/build-spec.md) for the build pipeline spec and
29
- [`docs/library-conversion-plan.md`](./docs/library-conversion-plan.md) for the
30
- phased plan to publish it as `@augeo/smelt`.
28
+ See [`docs/build-spec.md`](./docs/build-spec.md) for the build pipeline spec.
31
29
 
32
30
  ## Tech Stack
33
31
 
@@ -203,6 +201,12 @@ vitest + `example/`'s typecheck/build/vitest/theme-check). CI runs both.
203
201
  - **Prefer the Grep tool over CLI `grep`** when searching file contents — it
204
202
  handles permissions cleanly. Use CLI `grep` only when piping into another
205
203
  shell command that the Grep tool can't express.
204
+ - **Use `npm run verify`, never ad-hoc `npx`.** `verify` auto-fixes lint
205
+ (Biome + Prettier across the whole tree) and type-checks in one pass — reach
206
+ for it as the default even when you just want a formatting/lint check. Don't
207
+ reach for `npx prettier`, `npx biome`, `npx tsc`, or the bare `lint`
208
+ sub-script directly — the scripts encode the right scope, config, and the
209
+ Biome/Prettier boundary. See [Formatting](#formatting).
206
210
 
207
211
  ## Working Style
208
212
 
package/README.md CHANGED
@@ -3,8 +3,8 @@
3
3
  [![Verify](https://github.com/AugeoCorp/smelt/actions/workflows/verify.yml/badge.svg)](https://github.com/AugeoCorp/smelt/actions/workflows/verify.yml)
4
4
 
5
5
  Build Shopify themes from colocated components. Author your `button/` directory
6
- once `button.liquid`, `button.ts`, `button.css`, `button.test.ts`,
7
- `button.schema.ts` and Smelt compiles it into the flat, namespaced files
6
+ once (`button.liquid`, `button.ts`, `button.css`, `button.test.ts`,
7
+ `button.schema.ts`), and Smelt compiles it into the flat, namespaced files
8
8
  Shopify expects.
9
9
 
10
10
  ## Why
@@ -19,7 +19,7 @@ Shopify expects.
19
19
  autocomplete derived from Shopify's authoritative JSON Schemas. The build
20
20
  executes the file and injects the result.
21
21
  - **Private blocks for free.** Nest a `blocks/` directory under a section or
22
- block and its children become private theme blocks emitted with Shopify's
22
+ block, and its children become private theme blocks, emitted with Shopify's
23
23
  `_` prefix and auto-merged into the parent's schema. No manual filename
24
24
  mangling.
25
25
  - **Additive build.** Only files prefixed `built--` (or `_built--` for private
@@ -35,6 +35,7 @@ Shopify expects.
35
35
  - [Authoring](#authoring)
36
36
  - [Schemas](#schemas)
37
37
  - [Private blocks](#private-blocks)
38
+ - [Block faces](#block-faces)
38
39
  - [Layering](#layering)
39
40
  - [Build](#build)
40
41
  - [Learn More](#learn-more)
@@ -89,8 +90,8 @@ smelt build
89
90
  ```
90
91
 
91
92
  Outputs `sections/built--sections--hero.liquid`,
92
- `snippets/built--components--card.liquid`, etc. flat and namespaced, the way
93
- Shopify wants.
93
+ `snippets/built--components--card.liquid`, etc. They're flat and namespaced, the
94
+ way Shopify wants.
94
95
 
95
96
  See the [📄 `example/`](./example) directory for a working consumer theme.
96
97
 
@@ -110,7 +111,7 @@ Each component is a directory under `src/<type>/<name>/` where `<type>` is
110
111
 
111
112
  | File | Role |
112
113
  | ------------------ | -------------------------------------------- |
113
- | `<name>.liquid` | Markup (required anchors the component) |
114
+ | `<name>.liquid` | Markup (required; anchors the component) |
114
115
  | `<name>.ts` | Bundled and injected into `{% javascript %}` |
115
116
  | `<name>.css` | Injected into `{% stylesheet %}` |
116
117
  | `<name>.schema.ts` | Typed schema (sections + blocks only) |
@@ -154,12 +155,12 @@ Types are codegen'd from Shopify's authoritative schemas in
154
155
  [`theme-liquid-docs`](https://github.com/Shopify/theme-liquid-docs). Update via
155
156
  `npm run docs:update`.
156
157
 
157
- Inline `{% schema %}` blocks in `.liquid` files are a build error single
158
+ Inline `{% schema %}` blocks in `.liquid` files are a build error: a single
158
159
  source of truth.
159
160
 
160
161
  ## Private blocks
161
162
 
162
- Shopify treats theme block files prefixed with `_` as **private** hidden from
163
+ Shopify treats theme block files prefixed with `_` as **private**: hidden from
163
164
  the merchant's block picker, renderable only via a parent's
164
165
  `{% content_for "blocks" %}`. Smelt expresses this structurally: a `blocks/`
165
166
  directory nested under a section or block emits its children with the `_` prefix
@@ -182,19 +183,67 @@ sections/built--sections--hero.liquid
182
183
  blocks/_built--sections--hero--blocks--feature.liquid
183
184
  ```
184
185
 
185
- The parent's schema auto-merges discovered children into its `blocks: []` array
186
- sorted by directory name and prepended; any explicit entries you list (e.g.
186
+ The parent's schema auto-merges discovered children into its `blocks: []` array,
187
+ sorted by directory name and prepended; any explicit entries you list (e.g.
187
188
  globally-shared block types) appended after. Nesting is recursive: a private
188
189
  block can have its own `blocks/` subdir.
189
190
 
190
191
  Top-level `src/blocks/*` files remain public (no `_` prefix).
191
192
 
193
+ ## Block faces
194
+
195
+ A `src/components/*` component compiles to a snippet — reusable through
196
+ `{% render %}`, but invisible to the theme editor. A **block face** also exposes
197
+ that same component as a theme block a merchant can drop onto a page, without
198
+ duplicating it. Declare one with a singular `block/` directory inside the
199
+ component:
200
+
201
+ ```
202
+ src/components/card/
203
+ ├── card.liquid # the reusable snippet
204
+ ├── card.css
205
+ └── block/
206
+ └── card.schema.ts # the block face's schema
207
+ ```
208
+
209
+ Compiles to **both**:
210
+
211
+ ```
212
+ snippets/built--components--card.liquid # the snippet, unchanged
213
+ blocks/built--components--card.liquid # the block face
214
+ ```
215
+
216
+ With just a schema (no `block/card.liquid`), the face is **mechanical**: the
217
+ build synthesizes the wrapper for you — rendering the component, mapping each
218
+ setting to a render arg of the same name, and passing `shopify_attributes`
219
+ through. So name your component's props to match the setting `id`s.
220
+
221
+ ```liquid
222
+ {% # blocks/built--components--card.liquid (generated) %}
223
+ {% render 'built--components--card',
224
+ title: block.settings.title,
225
+ body: block.settings.body,
226
+ shopify_attributes: block.shopify_attributes
227
+ %}
228
+ {% schema %}…{% endschema %}
229
+ ```
230
+
231
+ When the wrapper isn't mechanical — derived props, conditional logic, or passing
232
+ `children: block.blocks` — add a `block/card.liquid` and the build uses it as
233
+ the body verbatim (renders rewritten, schema injected). The face is then just a
234
+ normal block component living in `block/`, so it can carry its own
235
+ `block/card.ts` / `block/card.css` too.
236
+
237
+ Faces are **components-only**: sections and blocks don't get them. A block face
238
+ can own private child blocks by nesting a `blocks/` directory inside `block/` —
239
+ see [`docs/build-spec.md`](./docs/build-spec.md) for that and the full rules.
240
+
192
241
  ## Layering
193
242
 
194
243
  Smelt walks an ordered list of layers and merges them per-file. The default is:
195
244
 
196
- 1. **Consumer** your theme's `src/` (`process.cwd()`).
197
- 2. **`@augeo/smelt`** the package's baseline `src/`.
245
+ 1. **Consumer:** your theme's `src/` (`process.cwd()`).
246
+ 2. **`@augeo/smelt`:** the package's baseline `src/`.
198
247
 
199
248
  For each component slot (`liquid`, `ts`, `css`, `schema`), the first layer that
200
249
  has the file wins. Drop just a `button.css` in your consumer to override styles;
@@ -207,7 +256,7 @@ smelt build
207
256
  ```
208
257
 
209
258
  Run from the theme root. Outputs go to `sections/built--*.liquid`,
210
- `blocks/built--*.liquid`, and `snippets/built--*.liquid` prefixed so they
259
+ `blocks/built--*.liquid`, and `snippets/built--*.liquid`, prefixed so they
211
260
  coexist with hand-written files in the same directories.
212
261
 
213
262
  ### Watch mode
@@ -219,7 +268,7 @@ smelt dev
219
268
  Runs an initial build, then watches each layer's `src/` and rebuilds on file
220
269
  changes (add, edit, delete). Build failures log and keep the watcher alive. Pair
221
270
  with `shopify theme dev` (in another terminal or via
222
- [`concurrently`](https://www.npmjs.com/package/concurrently)) `smelt dev`
271
+ [`concurrently`](https://www.npmjs.com/package/concurrently)): `smelt dev`
223
272
  writes the built files; `shopify theme dev` uploads them.
224
273
 
225
274
  ### Committing the output
@@ -228,7 +277,7 @@ Shopify imports themes from git, so `built--*` files (and `_built--*` for
228
277
  private blocks) need to be **committed** alongside source. Two configs keep the
229
278
  noise down:
230
279
 
231
- `.gitattributes` marks output as generated so GitHub collapses it in PR diffs
280
+ `.gitattributes` marks output as generated so GitHub collapses it in PR diffs
232
281
  and excludes it from language stats:
233
282
 
234
283
  ```
@@ -238,7 +287,7 @@ blocks/built--*.liquid linguist-generated=true
238
287
  blocks/_built--*.liquid linguist-generated=true
239
288
  ```
240
289
 
241
- `.prettierignore` skips the output so Prettier doesn't reformat it between
290
+ `.prettierignore` skips the output so Prettier doesn't reformat it between
242
291
  builds:
243
292
 
244
293
  ```
@@ -248,19 +297,70 @@ blocks/built--*.liquid
248
297
  blocks/_built--*.liquid
249
298
  ```
250
299
 
300
+ ### CI: verify the build is committed
301
+
302
+ Because the `built--*` files are committed, they can drift from source: someone
303
+ edits a component but forgets to rebuild, or commits a stale build. Catch it in
304
+ CI by rebuilding and failing if the working tree is dirty: a clean tree means
305
+ the committed output already matches source.
306
+
307
+ `.github/workflows/verify.yml`:
308
+
309
+ ```yaml
310
+ name: Verify
311
+
312
+ on:
313
+ push:
314
+ branches: [main]
315
+ pull_request:
316
+
317
+ jobs:
318
+ verify:
319
+ runs-on: ubuntu-latest
320
+ steps:
321
+ - uses: actions/checkout@v5
322
+
323
+ - uses: actions/setup-node@v5
324
+ with:
325
+ node-version-file: "package.json"
326
+ cache: "npm"
327
+
328
+ - run: npm ci
329
+
330
+ - name: Build
331
+ run: npm run build # → smelt build
332
+
333
+ - name: Check built files match source
334
+ run: |
335
+ if [ -n "$(git status --porcelain)" ]; then
336
+ echo "::error::Build produced uncommitted changes. Did you forget to commit a rebuild?"
337
+ git status --porcelain
338
+ git diff
339
+ exit 1
340
+ fi
341
+ ```
342
+
343
+ The build is [additive](#build) and deterministic: it only writes `built--*`
344
+ files, so a rebuild on a current tree produces no diff. Any change to
345
+ `git status` means the commit is missing a rebuild.
346
+
347
+ If you run other checks (tests, `shopify theme check`), put the build step
348
+ **first** so the job fails fast when the committed output is stale. There's no
349
+ point linting and testing a tree you already know is out of date. See this
350
+ repo's own [`verify.yml`](./.github/workflows/verify.yml) for the full pattern,
351
+ including git submodules and a Playwright browser for the test suite.
352
+
251
353
  ## Learn More
252
354
 
253
- - [📄 Build Spec](./docs/build-spec.md) pipeline, alias rules, conventions
254
- - [📄 Testing Conventions](./docs/TESTING.md) how to write tests against the
355
+ - [📄 Build Spec](./docs/build-spec.md): pipeline, alias rules, conventions
356
+ - [📄 Testing Conventions](./docs/TESTING.md): how to write tests against the
255
357
  built output
256
- - [📄 Library Conversion Plan](./docs/library-conversion-plan.md) phased plan
257
- for publishing
258
- - [`@augeo/assay`](https://www.npmjs.com/package/@augeo/assay) — the test runner
358
+ - [`@augeo/assay`](https://www.npmjs.com/package/@augeo/assay): the test runner
259
359
  Smelt uses
260
360
 
261
361
  ## Future Plans
262
362
 
263
- - **`smelt.config.ts`** consumer-defined layer list, enabling N-layer
363
+ - **`smelt.config.ts`:** consumer-defined layer list, enabling N-layer
264
364
  composition (e.g., a community component pack between consumer and baseline).
265
- - **More baseline components** currently just `button` demo. Expanding to a
365
+ - **More baseline components.** Currently just `button` demo. Expanding to a
266
366
  real default set.
package/dist/cli.mjs CHANGED
@@ -11,30 +11,30 @@ const TYPE_NAMES = [
11
11
  "blocks",
12
12
  "components"
13
13
  ];
14
+ const FACE_MARKER = "block";
14
15
  async function resolveLayers(layers) {
15
16
  const perLayerComponents = await Promise.all(layers.map(walkLayer));
16
17
  const allKeys = /* @__PURE__ */ new Map();
17
- perLayerComponents.flat().forEach(({ type, segments }) => {
18
- const key = `${type}:${segments.join("/")}`;
19
- if (!allKeys.has(key)) allKeys.set(key, {
20
- type,
21
- segments
22
- });
18
+ perLayerComponents.flat().forEach((descriptor) => {
19
+ const key = `${descriptor.type}:${descriptor.segments.join("/")}`;
20
+ if (!allKeys.has(key)) allKeys.set(key, descriptor);
23
21
  });
24
- return (await Promise.all(Array.from(allKeys.values()).map(async ({ type, segments }) => {
22
+ return (await Promise.all(Array.from(allKeys.values()).map(async (descriptor) => {
23
+ const anchor = descriptor.segments[descriptor.segments.length - 1];
25
24
  const [liquid, ts, css, test, schema] = await Promise.all([
26
- firstExisting(layers, segments, ".liquid"),
27
- firstExisting(layers, segments, ".ts"),
28
- firstExisting(layers, segments, ".css"),
29
- firstExisting(layers, segments, ".test.ts"),
30
- firstExisting(layers, segments, ".schema.ts")
25
+ firstExisting(layers, descriptor.dir, anchor, ".liquid"),
26
+ firstExisting(layers, descriptor.dir, anchor, ".ts"),
27
+ firstExisting(layers, descriptor.dir, anchor, ".css"),
28
+ firstExisting(layers, descriptor.dir, anchor, ".test.ts"),
29
+ firstExisting(layers, descriptor.dir, anchor, ".schema.ts")
31
30
  ]);
32
- if (!liquid) return void 0;
31
+ if (!liquid && !(descriptor.face && schema)) return void 0;
33
32
  const component = {
34
- type,
35
- segments,
36
- liquid
33
+ type: descriptor.type,
34
+ segments: descriptor.segments
37
35
  };
36
+ if (descriptor.face) component.face = true;
37
+ if (liquid) component.liquid = liquid;
38
38
  if (ts) component.ts = ts;
39
39
  if (css) component.css = css;
40
40
  if (test) component.test = test;
@@ -48,26 +48,35 @@ async function walkLayer(layer) {
48
48
  return (await Promise.all(TYPE_NAMES.map(async (type) => {
49
49
  const typeDir = join(sourceRoot, type);
50
50
  if (!await exists$1(typeDir)) return [];
51
- return walkType(typeDir, type, [type]);
51
+ return walkType(typeDir, type, [type], [type]);
52
52
  }))).flat();
53
53
  }
54
- async function walkType(directory, type, segments) {
54
+ async function walkType(directory, type, segments, dir) {
55
55
  const directories = (await readdir(directory, { withFileTypes: true })).filter((entry) => entry.isDirectory());
56
+ const inComponentContext = !TYPE_NAMES.includes(segments[segments.length - 1] ?? "");
56
57
  return (await Promise.all(directories.map(async (entry) => {
57
58
  const subdirectory = join(directory, entry.name);
58
- if (TYPE_NAMES.includes(entry.name)) return walkType(subdirectory, entry.name, [...segments, entry.name]);
59
+ if (TYPE_NAMES.includes(entry.name)) return walkType(subdirectory, entry.name, [...segments, entry.name], [...dir, entry.name]);
60
+ if (entry.name === FACE_MARKER && inComponentContext && type === "components") return [{
61
+ type: "blocks",
62
+ segments: [...segments],
63
+ dir: [...dir, entry.name],
64
+ face: true
65
+ }, ...await walkType(subdirectory, type, [...segments], [...dir, entry.name])];
59
66
  const componentSegments = [...segments, entry.name];
67
+ const componentDir = [...dir, entry.name];
60
68
  const here = await exists$1(join(subdirectory, `${entry.name}.liquid`)) ? [{
61
69
  type,
62
- segments: componentSegments
70
+ segments: componentSegments,
71
+ dir: componentDir,
72
+ face: false
63
73
  }] : [];
64
- const deeper = await walkType(subdirectory, type, componentSegments);
74
+ const deeper = await walkType(subdirectory, type, componentSegments, componentDir);
65
75
  return [...here, ...deeper];
66
76
  }))).flat();
67
77
  }
68
- async function firstExisting(layers, segments, extension) {
69
- const last = segments[segments.length - 1];
70
- const relativePath = join("src", ...segments, `${last}${extension}`);
78
+ async function firstExisting(layers, dir, anchor, extension) {
79
+ const relativePath = join("src", ...dir, `${anchor}${extension}`);
71
80
  const winner = (await Promise.all(layers.map(async (layer) => ({
72
81
  layer,
73
82
  path: join(layer.path, relativePath),
@@ -100,7 +109,7 @@ async function build$2(context) {
100
109
  if (!outputRoot) throw new Error("build() requires at least one layer.");
101
110
  const components = await resolveLayers(context.layers);
102
111
  await cleanBuiltOutputs(outputRoot);
103
- const lookup = new Map(components.map((component) => [component.segments.join("/"), component]));
112
+ const lookup = new Map(components.filter((component) => !component.face).map((component) => [component.segments.join("/"), component]));
104
113
  await Promise.all(components.map((component) => buildComponent(component, lookup, outputRoot)));
105
114
  return components.length;
106
115
  }
@@ -113,12 +122,23 @@ async function cleanBuiltOutputs(outputRoot) {
113
122
  }));
114
123
  }
115
124
  async function buildComponent(component, lookup, outputRoot) {
116
- const source = await readFile(component.liquid.path, "utf-8");
117
- assertNoInlineSchema(source, component);
118
- const body = rewriteRenders(source, component, lookup);
119
- const sections = [banner(component), body.trimEnd()];
120
- if (component.schema) {
121
- const merged = mergePrivateBlocks(await loadSchema(component.schema), component, lookup);
125
+ const schema = component.schema ? await loadSchema(component.schema) : void 0;
126
+ let body;
127
+ if (component.liquid) {
128
+ const source = await readFile(component.liquid.path, "utf-8");
129
+ assertNoInlineSchema(source, component);
130
+ body = rewriteRenders(source, component, lookup);
131
+ } else {
132
+ const snippetKey = component.segments.join("/");
133
+ if (!lookup.has(snippetKey)) {
134
+ const name = component.segments[component.segments.length - 1];
135
+ throw new Error(`Block face '${snippetKey}' has no underlying snippet to render — a mechanical face wraps its component. Add ${name}.liquid, or author a block/${name}.liquid override.`);
136
+ }
137
+ body = rewriteRenders(generateFaceBody(component, schema), component, lookup);
138
+ }
139
+ const sections = [banner(anchorSourceOf(component)), body.trimEnd()];
140
+ if (schema !== void 0) {
141
+ const merged = mergePrivateBlocks(schema, component, lookup);
122
142
  sections.push(renderSchemaBlock(merged));
123
143
  }
124
144
  if (component.css) {
@@ -135,7 +155,7 @@ async function buildComponent(component, lookup, outputRoot) {
135
155
  function assertNoInlineSchema(source, component) {
136
156
  if (!/\{%-?\s*schema\s*-?%\}/.test(source)) return;
137
157
  const last = component.segments[component.segments.length - 1];
138
- throw new Error(`Inline {% schema %} block in ${sourcePathForComponent(component)}. Schemas must live in a sibling ${last}.schema.ts file — import { defineSchemaSection } (or defineSchemaBlock) from "@augeo/smelt/schema".`);
158
+ throw new Error(`Inline {% schema %} block in ${sourcePathFor(anchorSourceOf(component))}. Schemas must live in a sibling ${last}.schema.ts file — import { defineSchemaSection } (or defineSchemaBlock) from "@augeo/smelt/schema".`);
139
159
  }
140
160
  async function loadSchema(schemaSource) {
141
161
  const code = await bundle(schemaSource.path, {
@@ -199,15 +219,40 @@ function findPrivateChildren(component, lookup) {
199
219
  function isPlainObject(value) {
200
220
  return typeof value === "object" && value !== null && !Array.isArray(value);
201
221
  }
202
- function banner(component) {
222
+ function banner(source) {
203
223
  return `{%- comment -%}
204
- GENERATED FROM ${sourcePathForComponent(component)} — do not edit this file directly.
224
+ GENERATED FROM ${sourcePathFor(source)} — do not edit this file directly.
205
225
  Edit the source and run \`npm run build\`.
206
226
  {%- endcomment -%}`;
207
227
  }
208
- function sourcePathForComponent(component) {
209
- const relativePath = relative(component.liquid.layer.path, component.liquid.path);
210
- return `${component.liquid.layer.name}/${relativePath}`;
228
+ function sourcePathFor(source) {
229
+ const relativePath = relative(source.layer.path, source.path);
230
+ return `${source.layer.name}/${relativePath}`;
231
+ }
232
+ /**
233
+ * The slot a built file traces back to: the hand-written liquid when present,
234
+ * else the schema (a mechanical block face is anchored on its schema alone).
235
+ */
236
+ function anchorSourceOf(component) {
237
+ const anchor = component.liquid ?? component.schema;
238
+ if (!anchor) throw new Error(`Component '${component.segments.join("/")}' has neither a liquid nor a schema file.`);
239
+ return anchor;
240
+ }
241
+ /**
242
+ * Synthesize a block face's wrapper body: render the underlying component,
243
+ * mapping each schema setting to a render arg of the same name (`id == arg`)
244
+ * and passing `shopify_attributes` through. The `@/...` render is rewritten to
245
+ * the built name by the caller. For non-mechanical wrappers (derived props,
246
+ * `block.blocks`, …) author a `block/<name>.liquid` override instead.
247
+ */
248
+ function generateFaceBody(component, schema) {
249
+ const settingArgs = (isPlainObject(schema) && Array.isArray(schema.settings) ? schema.settings : []).filter((setting) => isPlainObject(setting) && typeof setting.id === "string").map((setting) => `\t${setting.id}: block.settings.${setting.id},`);
250
+ return [
251
+ `{% render '${`@/${component.segments.join("/")}`}',`,
252
+ ...settingArgs,
253
+ " shopify_attributes: block.shopify_attributes",
254
+ "%}"
255
+ ].join("\n");
211
256
  }
212
257
  function wrapEmbedded(tag, body) {
213
258
  return `{% ${tag} %}\n${indent(body.trimEnd())}\n{% end${tag} %}`;
@@ -220,14 +265,14 @@ function rewriteRenders(source, component, lookup) {
220
265
  return source.replace(/(\{%-?\s*(?:render|include)\s+['"])((?:@|\.)\/[^'"]+)(['"])/g, (_match, prefix, importPath, suffix) => {
221
266
  const lookupKey = resolveImport(importPath, component).join("/");
222
267
  const target = lookup.get(lookupKey);
223
- if (!target) throw new Error(`Cannot resolve render '${importPath}' (looked up '${lookupKey}') from ${component.liquid.path}`);
268
+ if (!target) throw new Error(`Cannot resolve render '${importPath}' (looked up '${lookupKey}') from ${anchorSourceOf(component).path}`);
224
269
  return `${prefix}${builtNameOf(target)}${suffix}`;
225
270
  });
226
271
  }
227
272
  function resolveImport(importPath, component) {
228
273
  if (importPath.startsWith("@/")) return importPath.slice(2).split("/").filter(Boolean);
229
274
  if (importPath.startsWith("./")) {
230
- if (importPath.includes("../")) throw new Error(`'../' is not supported in render path: '${importPath}' (in ${component.liquid.path})`);
275
+ if (importPath.includes("../")) throw new Error(`'../' is not supported in render path: '${importPath}' (in ${anchorSourceOf(component).path})`);
231
276
  const remaining = importPath.slice(2).split("/").filter(Boolean);
232
277
  return [...component.segments, ...remaining];
233
278
  }
@@ -117,10 +117,10 @@ aliases (modeled on Next.js / TypeScript path conventions):
117
117
 
118
118
  <!-- prettier-ignore -->
119
119
  ```liquid
120
- {# inside src/sections/hero/hero.liquid: #}
120
+ {% # inside src/sections/hero/hero.liquid: %}
121
121
 
122
- {% render './components/foo' %} {# nested child of hero #}
123
- {% render '@/components/button' %} {# top-level shared component #}
122
+ {% render './components/foo' %} {% # nested child of hero %}
123
+ {% render '@/components/button' %} {% # top-level shared component %}
124
124
  ```
125
125
 
126
126
  Both rewrite to namespaced output names:
@@ -133,7 +133,8 @@ Both rewrite to namespaced output names:
133
133
  Hand-written snippets remain referenceable by their bare name as usual:
134
134
 
135
135
  ```liquid
136
- {% render 'icon-cart' %} {# unchanged — resolves to snippets/icon-cart.liquid #}
136
+ {% render 'icon-cart' %}
137
+ {% # unchanged — resolves to snippets/icon-cart.liquid %}
137
138
  ```
138
139
 
139
140
  ### TS / CSS — colocated and implicit
@@ -249,6 +250,97 @@ The parent section or block's schema auto-merges these private children into its
249
250
  prepended, any blocks the author explicitly lists (e.g. globally-shared block
250
251
  types) appended after. No helper call required.
251
252
 
253
+ ### Block faces — nested `block/`
254
+
255
+ A `src/components/*` component is a snippet — reusable through
256
+ `{% render '@/components/...' %}`, but invisible to the theme editor. A **block
257
+ face** exposes that same component as a theme block a merchant can add, without
258
+ duplicating it. It's declared structurally: a singular `block/` directory nested
259
+ inside the component.
260
+
261
+ ```
262
+ src/components/image/
263
+ ├── image.liquid # the reusable snippet
264
+ ├── image.css
265
+ └── block/
266
+ └── image.schema.ts # the block face's schema
267
+ ```
268
+
269
+ Compiles to **both**:
270
+
271
+ ```
272
+ snippets/built--components--image.liquid # the snippet, unchanged
273
+ blocks/built--components--image.liquid # the block face
274
+ ```
275
+
276
+ `block/` is a **zero-segment marker**. Unlike the plural `blocks/` type marker
277
+ (which contributes a `blocks` segment and holds private children), singular
278
+ `block/` contributes nothing to the naming segments — it only retargets the
279
+ component's type to `blocks` and reroots file lookup inside `block/`. That's why
280
+ the face is named `built--components--image` (after the parent), not
281
+ `…--image--block`, and why it lands in `blocks/` rather than `snippets/`. The
282
+ face's anchor files are named after the **parent** component (`image.liquid`,
283
+ `image.schema.ts`), so the source basename still equals the output token, as
284
+ everywhere else.
285
+
286
+ > Note the singular/plural distinction: `block/` (one face) vs `blocks/` (many
287
+ > private children). They can nest — see the `tabs` example below.
288
+
289
+ The face's schema is authored exactly like any block schema — a sibling
290
+ `image.schema.ts` exporting `schema` (via `defineSchemaBlock`). It needs no new
291
+ concepts; the `block/` directory is the only signal.
292
+
293
+ **Mechanical vs. override.** Two ways to author the face body:
294
+
295
+ - **Mechanical (schema only).** With just `block/<name>.schema.ts` and no
296
+ `block/<name>.liquid`, the engine **synthesizes** the wrapper: it renders the
297
+ underlying component, mapping each schema setting to a render arg of the same
298
+ name (`id == arg`) and passing `shopify_attributes` through. This covers the
299
+ common case where every setting maps 1:1 to a prop.
300
+
301
+ ```liquid
302
+ {%- comment -%} GENERATED … {%- endcomment -%}
303
+ {% render 'built--components--image',
304
+ src: block.settings.src,
305
+ shopify_attributes: block.shopify_attributes
306
+ %}
307
+ {% schema %}…{% endschema %}
308
+ ```
309
+
310
+ Because the mechanism is `id == arg`, **name the component's props to match
311
+ the setting ids.** There is no mapping table.
312
+
313
+ - **Override (`block/<name>.liquid`).** When the wrapper isn't mechanical —
314
+ derived props, conditional logic, or passing `children: block.blocks` — drop a
315
+ `block/<name>.liquid` and the engine uses it as the body verbatim (rewriting
316
+ its `@/...` renders, stamping the banner, injecting the schema). The face is
317
+ then just a normal block component living in `block/`, so it may also carry
318
+ its own `block/<name>.ts` / `block/<name>.css`.
319
+
320
+ **Private children of a face.** Because private children belong to the block —
321
+ not the snippet — they nest under the face via the plural `blocks/` marker:
322
+
323
+ ```
324
+ src/components/tabs/
325
+ ├── tabs.liquid
326
+ └── block/
327
+ ├── tabs.liquid # override — renders children: block.blocks
328
+ ├── tabs.schema.ts
329
+ └── blocks/
330
+ └── tab/
331
+ ├── tab.liquid
332
+ └── tab.schema.ts
333
+ ```
334
+
335
+ The singular `block/` adds no segment and the plural `blocks/` adds `blocks`, so
336
+ the child resolves to `_built--components--tabs--blocks--tab` and auto-merges
337
+ into the face's `blocks: [...]` — the existing private-block machinery,
338
+ unchanged.
339
+
340
+ A mechanical face requires the underlying snippet to exist (it renders it); if
341
+ the snippet is missing the render won't resolve. A thing that is purely a block
342
+ with no reusable-snippet angle should just live in `src/blocks/`.
343
+
252
344
  ## Build Pipeline
253
345
 
254
346
  The engine lives in `lib/build/build.ts` and is invoked by `lib/cli.ts` (a citty
@@ -266,8 +358,11 @@ rebuilds the bin and `npm run test:all` runs the full integration chain against
266
358
  `sections/`, `snippets/`, and `blocks/`. Output is regenerated from scratch
267
359
  on every build, so removed source components disappear cleanly. Hand-written
268
360
  files without the prefix are untouched.
269
- 3. **For each component** (anything with a `.liquid`):
270
- - Prepend a `{%- comment -%}` banner naming the source file, for tracing.
361
+ 3. **For each component** (anything with a `.liquid`, plus mechanical block
362
+ faces anchored on a schema alone see
363
+ [Block faces](#block-faces--nested-block)):
364
+ - Prepend a `{%- comment -%}` banner naming the source file (the `.liquid`,
365
+ or for a mechanical face its `.schema.ts`), for tracing.
271
366
  - Bundle `<name>.ts` (esbuild, IIFE, utilities inlined) → string.
272
367
  - Read `<name>.css` as-is (no transform today — see TODO).
273
368
  - Execute `<name>.schema.ts` — esbuild → `data:` URL → dynamic `import()`,
@@ -277,7 +372,10 @@ rebuilds the bin and `npm run test:all` runs the full integration chain against
277
372
  - Auto-merge any immediate private `blocks/` children into the loaded
278
373
  schema's `blocks: [...]` array (see
279
374
  [Private blocks](#private-blocks--nested-blocks)).
280
- - Transform `<name>.liquid`:
375
+ - Produce the body — either the source `<name>.liquid`, or, for a mechanical
376
+ block face (no `.liquid`), a synthesized wrapper that renders the
377
+ underlying component with each schema setting mapped to a render arg and
378
+ `shopify_attributes` passed through. Then:
281
379
  - **Error** if the source `.liquid` contains an inline `{% schema %}` block
282
380
  — schemas must live in `<name>.schema.ts`.
283
381
  - Rewrite `{% render '@/...' %}` and `{% render './...' %}` to namespaced