@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.
- package/.github/workflows/verify.yml +23 -10
- package/AGENTS.md +7 -3
- package/README.md +123 -23
- package/dist/cli.mjs +84 -39
- package/dist/schema.d.mts +8 -6
- package/dist/schema.mjs +6 -4
- 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/lib/schema.ts +8 -10
- 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/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.
|
|
252
|
-
*
|
|
253
|
-
* for
|
|
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
|
|
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
|
|
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.
|
|
4
|
-
*
|
|
5
|
-
* for
|
|
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
|
*/
|