@augeo/smelt 1.2.2 → 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
  }
package/dist/schema.d.mts CHANGED
@@ -248,18 +248,20 @@ interface SectionToggle1 {
248
248
  //#endregion
249
249
  //#region lib/schema.d.ts
250
250
  /**
251
- * Identity helper for authoring a Shopify section schema. The `const T`
252
- * parameter preserves literal types so the schema author gets autocomplete
253
- * for setting `type` values, `id`s, etc.
251
+ * Identity helper for authoring a Shopify section schema. Typing the parameter
252
+ * as `SectionSchema` directly (rather than a generic `<const T>`) gives the
253
+ * author both autocomplete for `type` values, `id`s, etc. and excess-property
254
+ * checking — a bare type parameter would silently accept unknown keys.
254
255
  *
255
256
  * Use in `<name>.schema.ts` files under `src/sections/`.
256
257
  */
257
- declare function defineSchemaSection<const T extends SectionSchema>(schema: T): T;
258
+ declare function defineSchemaSection(schema: SectionSchema): SectionSchema;
258
259
  /**
259
- * Identity helper for authoring a Shopify theme-block schema.
260
+ * Identity helper for authoring a Shopify theme-block schema. See
261
+ * {@link defineSchemaSection} for why the parameter is typed directly.
260
262
  *
261
263
  * Use in `<name>.schema.ts` files under `src/blocks/`.
262
264
  */
263
- declare function defineSchemaBlock<const T extends ThemeBlockSchema>(schema: T): T;
265
+ declare function defineSchemaBlock(schema: ThemeBlockSchema): ThemeBlockSchema;
264
266
  //#endregion
265
267
  export { type SectionSchema, type ThemeBlockSchema, defineSchemaBlock, defineSchemaSection };
package/dist/schema.mjs CHANGED
@@ -1,8 +1,9 @@
1
1
  //#region lib/schema.ts
2
2
  /**
3
- * Identity helper for authoring a Shopify section schema. The `const T`
4
- * parameter preserves literal types so the schema author gets autocomplete
5
- * for setting `type` values, `id`s, etc.
3
+ * Identity helper for authoring a Shopify section schema. Typing the parameter
4
+ * as `SectionSchema` directly (rather than a generic `<const T>`) gives the
5
+ * author both autocomplete for `type` values, `id`s, etc. and excess-property
6
+ * checking — a bare type parameter would silently accept unknown keys.
6
7
  *
7
8
  * Use in `<name>.schema.ts` files under `src/sections/`.
8
9
  */
@@ -10,7 +11,8 @@ function defineSchemaSection(schema) {
10
11
  return schema;
11
12
  }
12
13
  /**
13
- * Identity helper for authoring a Shopify theme-block schema.
14
+ * Identity helper for authoring a Shopify theme-block schema. See
15
+ * {@link defineSchemaSection} for why the parameter is typed directly.
14
16
  *
15
17
  * Use in `<name>.schema.ts` files under `src/blocks/`.
16
18
  */