@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.
- package/.github/workflows/verify.yml +23 -10
- package/AGENTS.md +7 -3
- package/README.md +123 -23
- package/dist/cli.mjs +84 -39
- package/docs/build-spec.md +105 -7
- package/docs/js-modules-plan.md +278 -0
- package/example/blocks/built--components--card.liquid +34 -0
- package/example/snippets/built--components--card.liquid +1 -1
- package/example/src/components/card/block/card.schema.ts +14 -0
- package/example/src/components/card/card.liquid +1 -1
- package/lib/build/build.test.ts +207 -0
- package/lib/build/build.ts +92 -18
- package/lib/resolver.test.ts +91 -4
- package/lib/resolver.ts +121 -36
- package/package.json +1 -1
- package/docs/library-conversion-plan.md +0 -419
|
@@ -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
|
|
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
|
[](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
|
|
7
|
-
`button.schema.ts`
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
197
|
-
2. **`@augeo/smelt
|
|
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
|
|
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))
|
|
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`
|
|
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`
|
|
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)
|
|
254
|
-
- [📄 Testing Conventions](./docs/TESTING.md)
|
|
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
|
-
- [
|
|
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
|
|
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
|
|
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((
|
|
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 (
|
|
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,
|
|
27
|
-
firstExisting(layers,
|
|
28
|
-
firstExisting(layers,
|
|
29
|
-
firstExisting(layers,
|
|
30
|
-
firstExisting(layers,
|
|
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,
|
|
69
|
-
const
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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 ${
|
|
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(
|
|
222
|
+
function banner(source) {
|
|
203
223
|
return `{%- comment -%}
|
|
204
|
-
GENERATED FROM ${
|
|
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
|
|
209
|
-
const relativePath = relative(
|
|
210
|
-
return `${
|
|
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.
|
|
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.
|
|
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/docs/build-spec.md
CHANGED
|
@@ -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' %}
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|