@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.
@@ -0,0 +1,278 @@
1
+ ---
2
+ status: draft
3
+ ---
4
+
5
+ # JavaScript Module Delivery
6
+
7
+ Plan for how a component's JavaScript reaches the browser as **shared ES
8
+ modules** instead of per-component inlined bundles, so a dependency used by N
9
+ components ships once. Companion to [`build-spec.md`](./build-spec.md) (which
10
+ describes today's `{% javascript %}` inlining) and a direct answer to its
11
+ standing TODO, _"Utility deduplication across component bundles."_
12
+
13
+ This doc is deliberately scoped to the **JS delivery strategy** — modules,
14
+ import maps, deduplication, and load placement. It is **not** about web
15
+ components, which are an optional authoring style that rides on top of
16
+ everything here and stays out of scope (see [Out of Scope](#out-of-scope)). The
17
+ two are orthogonal: every byte-level win below comes from modules + import maps,
18
+ not from custom elements.
19
+
20
+ ## The problem this solves
21
+
22
+ Today `bundleJs` (`lib/build/build.ts`) runs each component's `<name>.ts`
23
+ through esbuild with `bundle: true`, format `iife`, and inlines the result into
24
+ a `{% javascript %}` block. esbuild does not distinguish a `src/utilities/`
25
+ import from a `node_modules` import — it inlines **both** into that component's
26
+ IIFE. So:
27
+
28
+ - Two components importing `debounce` from `lodash-es` each ship their own copy.
29
+ - The same is true for any shared `src/utilities/` helper.
30
+
31
+ Crucially, **Shopify does not rescue us here.** Its `{% javascript %}`
32
+ deduplication is keyed on the _file_, not the _content_: `{% javascript %}`
33
+ blocks are concatenated into one bundle per file type (`scripts.js` /
34
+ `block-scripts.js` / `snippet-scripts.js`), and _"bundled assets are only
35
+ injected once for each section, block or snippet file, not for each instance of
36
+ that file."_ That dedupes **instances of one component**, but two **different**
37
+ built files with byte-identical content produce two copies in the concatenated
38
+ bundle. The duplication is real and grows linearly with no platform relief.
39
+
40
+ ## Goal
41
+
42
+ Let a component author write the most boring thing possible —
43
+
44
+ ```ts
45
+ import { debounce } from "lodash-es";
46
+ import { formatMoney } from "@/utilities/money";
47
+ ```
48
+
49
+ — and have the build deliver `lodash-es` and `money` as **shared module assets
50
+ imported by URL**, so the browser fetches and caches each exactly once across
51
+ every component that uses it. The colocated authoring model
52
+ (`button/{button.liquid,button.ts,button.css}`), the layer/resolver system, and
53
+ the `@/` alias all stay exactly as they are. Only the _packaging and delivery_
54
+ of the compiled JS changes.
55
+
56
+ ## The shape
57
+
58
+ ### 1. Each component's JS compiles to a module asset
59
+
60
+ `<name>.ts` is bundled as an **ES module** written to `assets/`, named with the
61
+ same `built--<segments>` scheme used everywhere else:
62
+
63
+ ```
64
+ src/components/button/button.ts
65
+ → assets/built--components--button.js
66
+ ```
67
+
68
+ The component's `.liquid` no longer carries an inlined `{% javascript %}` block;
69
+ its behavior lives in the module asset and is loaded as
70
+ `<script type="module" src="{{ 'built--components--button.js' | asset_url }}">`
71
+ (see [Load placement](#3-load-placement--scoped-vs-global)).
72
+
73
+ ### 2. Shared code is imported, not inlined — and an import map dedupes it
74
+
75
+ Shared `src/utilities/*` and external `node_modules` packages are marked
76
+ `external` to esbuild (not inlined). They become real `import` statements
77
+ resolved at runtime through a generated **import map**. Because the browser
78
+ caches modules by resolved URL, a utility imported by ten components is fetched,
79
+ parsed, and evaluated **once**.
80
+
81
+ The import map is the runtime twin of Smelt's build-time `@/` alias — the same
82
+ move Shopify's flagship Horizon theme makes with its `@theme/` alias. The build
83
+ emits a snippet, e.g. `snippets/built--scripts.liquid`:
84
+
85
+ ```liquid
86
+ <script type="importmap">
87
+ {
88
+ "imports": {
89
+ "@/utilities/money": "{{ 'built--utilities--money.js' | asset_url }}",
90
+ "lodash-es": "{{ 'built--vendor--lodash-es.js' | asset_url }}"
91
+ }
92
+ }
93
+ </script>
94
+ ```
95
+
96
+ The consumer renders that snippet once. Thanks to Shopify's
97
+ [resilient import maps](https://shopify.engineering/resilient-import-maps) work,
98
+ **multiple import maps merge and are loading-order independent**, so Smelt's
99
+ generated map coexists with a consumer's (or Horizon's) without owning a single
100
+ canonical `<head>` slot — and Shopify auto-injects the `es-module-shims`
101
+ polyfill for browsers lacking native support. This is what keeps the approach
102
+ **additive**: we emit a `built--` snippet, we never edit the consumer's
103
+ `layout/`.
104
+
105
+ ### 3. Load placement — scoped vs global
106
+
107
+ Import maps resolve specifiers to URLs; they do **not** decide _which_ module
108
+ script tags a given page includes. That is a separate, important choice:
109
+
110
+ - **Global registry** (Horizon's default): one snippet lists every component's
111
+ `<script type="module">`, included on every page. Simple, fully robust against
112
+ the merchant composing any component into any section at runtime — but it
113
+ **downloads, parses, and evaluates the JS for unused components on every
114
+ page.** (A web-component definition's `connectedCallback` won't _run_ without
115
+ its tag present, but the module is still shipped, parsed, and registered. WC
116
+ saves execution, not loading.)
117
+ - **Section-scoped loading** (recommended — Smelt's compiler edge): because the
118
+ build rewrites every `{% render %}`, it knows which components a section pulls
119
+ in. Emit a component's `<script type="module">` **with the section/snippet
120
+ that renders it**, so the module loads only on pages where that section is
121
+ present. Page weight tracks components _actually rendered_, not the total
122
+ catalog.
123
+
124
+ The safe granularity for scoping is **"section/block presence," not "page"** —
125
+ runtime composition (theme editor adding blocks) means the build can prove "this
126
+ section renders a button" but not "this rendered page has no button." Scoping to
127
+ the rendering unit stays correct under dynamic composition.
128
+
129
+ These are not mutually exclusive — a global registry for a small core plus
130
+ section-scoped loading for the rest mirrors what Horizon actually ships.
131
+
132
+ ### 4. Tree-shaking comes from the build, not the import map
133
+
134
+ An import map only maps specifier → URL; it neither bundles nor shakes. Real
135
+ dead-code elimination and shared-chunk extraction come from running esbuild with
136
+ `format: "esm"` and `splitting: true` over the component graph, so the **union**
137
+ of used exports lands in shared chunks and unused code is dropped. Shipping
138
+ whole-package vendor modules is the acceptable first cut; splitting is the
139
+ optimization when measured weight justifies it.
140
+
141
+ ### 5. External npm packages use the same path
142
+
143
+ There is no separate mechanism for third-party modules. A `node_modules` package
144
+ is externalized, emitted (or referenced) as a module asset, and mapped in the
145
+ import map — exactly like a `src/utilities/` helper. A package can even map
146
+ straight to a vendor CDN URL (Horizon maps `@shopify/events` to a
147
+ `cdn.shopify.com` URL this way) when self-hosting isn't wanted.
148
+
149
+ ## How this interacts with `{% javascript %}`
150
+
151
+ `{% javascript %}` content is a classic IIFE; an import map only governs
152
+ **module** resolution. Static `import` is illegal in that context, so the two do
153
+ not mix at the statement level. **Mix at the component boundary instead:**
154
+
155
+ - A component with **no shared dependency** can stay an inlined
156
+ `{% javascript %}` block (today's model — simple, no asset proliferation).
157
+ - A component that **imports a shared utility or external package** graduates to
158
+ a module asset.
159
+
160
+ The switch is the _presence of a shared import_. Do not try to make a single
161
+ component half-classic / half-module — that forces transforming static imports
162
+ into awaited dynamic `import()` and silently makes init async. Keep each
163
+ component wholly one or the other.
164
+
165
+ ## Design Decisions
166
+
167
+ ### Modules + import map over a shared global or per-utility snippet guard
168
+
169
+ The build-spec TODO lists three candidates: a single
170
+ `assets/built--utilities.js` on a global, ES modules via an import map, and
171
+ per-utility snippet guards. This plan selects **ES modules via an import map**.
172
+
173
+ - **Why:** the browser dedupes by URL natively (execute-once-per-URL), import
174
+ order is resolved by the module graph rather than by a fragile load-order
175
+ convention, scoping is clean, and it is the idiomatic modern-Shopify path
176
+ (Horizon is built on it). The global-bundle option pollutes a namespace and
177
+ couples load order; the snippet-guard option leans on a `window.__smelt`
178
+ namespace plus a `DOMContentLoaded` discipline.
179
+ - **Cost:** component init becomes async-_loaded_ (gated on the import graph),
180
+ and the approach depends on Shopify's recent resilient-import-maps platform
181
+ work. Ordering, by contrast, becomes a module-system _guarantee_ rather than a
182
+ discipline — a net improvement on the axis we care about.
183
+
184
+ ### Delivery is build-internal; the authoring contract is not
185
+
186
+ Moving from inlined IIFE to module asset is a change to **how the build packages
187
+ the same source** — the author's `.ts` is unchanged for plain "enhance-the-DOM"
188
+ code. That makes this migration cheap, reversible, and deferrable. The
189
+ expensive, distributed change is anything that touches the **public authoring
190
+ contract** (e.g. mandating web components). Keep delivery swappable and the
191
+ authoring contract deliberate.
192
+
193
+ - **Why:** preserves the option to adopt — or never adopt — web components
194
+ without a rewrite.
195
+ - **Cost:** none today; it is a constraint on how we _describe_ the contract.
196
+
197
+ ### Keep component JS idempotent DOM enhancement
198
+
199
+ Author component JS as side-effect-light, presence-checking, idempotent DOM
200
+ enhancement (find my elements, enhance them) rather than "run once at load,
201
+ mutate globals."
202
+
203
+ - **Why:** that single habit slots cleanly into a section-scoped plain-module
204
+ world _and_ a web-component (`connectedCallback`) world later, with no
205
+ rewrite. It is also what survives theme-editor section re-renders.
206
+ - **Cost:** a mild authoring discipline, already implied by "no Liquid inside
207
+ `{% javascript %}`" (component JS is already Liquid-free and
208
+ attribute-driven).
209
+
210
+ ## Out of Scope
211
+
212
+ - **Web components.** Custom elements are an optional authoring style layered on
213
+ top of this. They buy ergonomics (auto-hydrate by tag presence, editor-safe
214
+ re-init) — **not** the byte/cache/dedup wins, which are all delivered here.
215
+ They are a separate decision with its own (public, breaking) contract cost.
216
+ - **CSS deduplication.** `{% stylesheet %}` has the same per-file model; whether
217
+ shared CSS wants the same module-style treatment is its own discussion.
218
+ - **Lazy / dynamic `import()` on interaction or visibility.** A real further win
219
+ for avoiding unused-component load, but a separate strategy from static module
220
+ delivery. Note and defer.
221
+ - **`props.ts` runtime validation** and other open `build-spec.md` questions.
222
+
223
+ ## Implementation Outline
224
+
225
+ Concrete-but-not-final list of where changes land:
226
+
227
+ 1. **`lib/resolver.ts`** — utilities (and externalized vendor packages) need to
228
+ be addressable as emittable module assets, not only as inlined imports.
229
+ Likely a utility slot that produces output, plus a way to enumerate the
230
+ external packages referenced across the component graph.
231
+ 2. **`lib/build/build.ts`**
232
+ - **`bundleJs`** — switch the component path from `format: "iife"` inlined
233
+ string to `format: "esm"` written to `assets/built--<segments>.js`, with
234
+ `external` covering `@/utilities/*` and bare `node_modules` specifiers.
235
+ Consider one `splitting: true` invocation over all module entry points for
236
+ cross-component tree-shaking + shared chunks.
237
+ - **`buildComponent`** — stop appending `{% javascript %}` for module
238
+ components; instead record the component's module asset + import-map entry,
239
+ and (for section-scoped loading) emit its `<script type="module">` into the
240
+ rendering unit.
241
+ - **import-map generation** — new step producing
242
+ `snippets/built--scripts.liquid` from the resolved utility + vendor set.
243
+ - **`cleanBuiltOutputs`** — extend the `/^_?built--/` sweep to `assets/` for
244
+ emitted `.js` (overlaps with [`assets-plan.md`](./assets-plan.md)).
245
+ 3. **`docs/build-spec.md`** — note that component JS may be delivered as a
246
+ module asset; cross-reference this plan from the dedup TODO.
247
+ 4. **`example/`** — two components importing the same `lodash-es` function plus
248
+ a shared `src/utilities/*` helper; verify the dep lands once in `assets/`,
249
+ appears once in the import map, theme-check passes, and assay browser tests
250
+ resolve the module assets.
251
+ 5. **Tests** — `lib/build/build.test.ts`: module-asset emission, import-map
252
+ contents, externalized-vs-inlined import handling, and the
253
+ `{% javascript %}`-vs-module component-boundary switch. Testing harness note:
254
+ assay/liquidjs must resolve module assets via `assetsPath` / the import map —
255
+ see [`TESTING.md`](./TESTING.md).
256
+
257
+ ## Open Questions
258
+
259
+ - **De-risk the externalization first.** The keystone is esbuild's handling of
260
+ `external` packages in module output and how the import-map entry resolves
261
+ them at runtime. Worth a throwaway prototype against `example/` before
262
+ committing — confirm a shared dep rewrites cleanly and lands once.
263
+ - **Whole-package vs union tree-shaking for vendors.** Ship the whole package
264
+ entry first, or compute the union of used exports across components from the
265
+ start? Whole-entry is simpler; union preserves shaking. Lean simple, measure,
266
+ optimize.
267
+ - **Default load placement.** Global registry (simplest, robust) vs
268
+ section-scoped (lighter pages, needs compiler wiring) vs a hybrid. Likely
269
+ hybrid, but the default and the authoring surface for opting in need a
270
+ decision.
271
+ - **Multiple versions of one package.** If two deps pin different versions of
272
+ the same package, one import-map specifier collides. Assume one version per
273
+ name in v1 and **fail loudly** if violated rather than silently serving the
274
+ wrong one (consistent with the no-silent-overwrite stance).
275
+ - **Uniform vs mixed output.** Is it acceptable for some components to emit
276
+ inlined `{% javascript %}` and others module assets, based on whether they
277
+ import shared code? More powerful, but a consumer debugging built output sees
278
+ two shapes. Uniformity has its own value.
@@ -0,0 +1,34 @@
1
+ {%- comment -%}
2
+ GENERATED FROM consumer/src/components/card/block/card.schema.ts — do not edit this file directly.
3
+ Edit the source and run `npm run build`.
4
+ {%- endcomment -%}
5
+
6
+ {% render 'built--components--card',
7
+ title: block.settings.title,
8
+ body: block.settings.body,
9
+ shopify_attributes: block.shopify_attributes
10
+ %}
11
+
12
+ {% schema %}
13
+ {
14
+ "name": "Card",
15
+ "settings": [
16
+ {
17
+ "type": "text",
18
+ "id": "title",
19
+ "label": "Title",
20
+ "default": "Untitled"
21
+ },
22
+ {
23
+ "type": "textarea",
24
+ "id": "body",
25
+ "label": "Body"
26
+ }
27
+ ],
28
+ "presets": [
29
+ {
30
+ "name": "Card"
31
+ }
32
+ ]
33
+ }
34
+ {% endschema %}
@@ -8,7 +8,7 @@
8
8
  assign body = body | default: ''
9
9
  -%}
10
10
 
11
- <article class="tr-card">
11
+ <article class="tr-card" {{ shopify_attributes }}>
12
12
  <h3 class="tr-card__title">{{ title }}</h3>
13
13
  <p class="tr-card__body">{{ body }}</p>
14
14
  </article>
@@ -0,0 +1,14 @@
1
+ import { defineSchemaBlock } from "@augeo/smelt/schema";
2
+
3
+ // A mechanical block face: no `card.liquid` override here, so the build
4
+ // synthesizes the wrapper render from these settings (id → render arg) and
5
+ // passes `shopify_attributes` through. The card snippet stays reusable via
6
+ // `{% render '@/components/card' %}`; this just also exposes it in the editor.
7
+ export const schema = defineSchemaBlock({
8
+ name: "Card",
9
+ settings: [
10
+ { type: "text", id: "title", label: "Title", default: "Untitled" },
11
+ { type: "textarea", id: "body", label: "Body" },
12
+ ],
13
+ presets: [{ name: "Card" }],
14
+ });
@@ -3,7 +3,7 @@
3
3
  assign body = body | default: ''
4
4
  -%}
5
5
 
6
- <article class="tr-card">
6
+ <article class="tr-card" {{ shopify_attributes }}>
7
7
  <h3 class="tr-card__title">{{ title }}</h3>
8
8
  <p class="tr-card__body">{{ body }}</p>
9
9
  </article>
@@ -436,6 +436,188 @@ describe("build()", () => {
436
436
  );
437
437
  });
438
438
  });
439
+
440
+ describe("when a component has a mechanical block face", () => {
441
+ beforeEach(async () => {
442
+ await writeComponent(baseline, "components/image", {
443
+ liquid: "<img>",
444
+ css: ".img {}",
445
+ });
446
+ });
447
+ beforeEach(async () => {
448
+ await writeFace(baseline, "components/image", {
449
+ schema:
450
+ "export const schema = { name: 'Image', settings: [{ type: 'image_picker', id: 'src', label: 'Image' }, { type: 'checkbox', id: 'rounded', label: 'Rounded' }] };",
451
+ });
452
+ });
453
+ beforeEach(async () => {
454
+ await build({ layers: [consumer.layer, baseline.layer] });
455
+ });
456
+
457
+ let face: string;
458
+ beforeEach(async () => {
459
+ face = await readFile(
460
+ join(consumer.path, "blocks/built--components--image.liquid"),
461
+ "utf-8",
462
+ );
463
+ });
464
+
465
+ it("emits the face into blocks/ named after the component", () => {
466
+ expect(face).toBeTruthy();
467
+ });
468
+
469
+ it("still emits the underlying snippet", async () => {
470
+ const snippet = await readFile(
471
+ join(consumer.path, "snippets/built--components--image.liquid"),
472
+ "utf-8",
473
+ );
474
+ expect(snippet).toContain("<img>");
475
+ });
476
+
477
+ it("renders the underlying component by its built name", () => {
478
+ expect(face).toContain("{% render 'built--components--image',");
479
+ });
480
+
481
+ it("maps each setting id to a render arg of the same name", () => {
482
+ expect(face).toContain("src: block.settings.src,");
483
+ expect(face).toContain("rounded: block.settings.rounded,");
484
+ });
485
+
486
+ it("passes shopify_attributes through", () => {
487
+ expect(face).toContain("shopify_attributes: block.shopify_attributes");
488
+ });
489
+
490
+ it("injects the face schema", () => {
491
+ expect(face).toContain("{% schema %}");
492
+ expect(face).toContain('"name": "Image"');
493
+ });
494
+ });
495
+
496
+ describe("when a mechanical face has no underlying snippet", () => {
497
+ beforeEach(async () => {
498
+ await writeFace(baseline, "components/orphan", {
499
+ schema: "export const schema = { name: 'Orphan' };",
500
+ });
501
+ });
502
+
503
+ it("fails with a face-specific error, not a generic render miss", async () => {
504
+ await expect(
505
+ build({ layers: [consumer.layer, baseline.layer] }),
506
+ ).rejects.toThrow(/no underlying snippet/);
507
+ });
508
+ });
509
+
510
+ describe("when a component has an override block face", () => {
511
+ beforeEach(async () => {
512
+ await writeComponent(baseline, "components/stack", {
513
+ liquid: "<div>stack</div>",
514
+ });
515
+ });
516
+ beforeEach(async () => {
517
+ await writeFace(baseline, "components/stack", {
518
+ liquid:
519
+ "{% render '@/components/stack', children: block.blocks, shopify_attributes: block.shopify_attributes %}",
520
+ schema: "export const schema = { name: 'Stack' };",
521
+ css: ".stack-block {}",
522
+ });
523
+ });
524
+ beforeEach(async () => {
525
+ await build({ layers: [consumer.layer, baseline.layer] });
526
+ });
527
+
528
+ let face: string;
529
+ beforeEach(async () => {
530
+ face = await readFile(
531
+ join(consumer.path, "blocks/built--components--stack.liquid"),
532
+ "utf-8",
533
+ );
534
+ });
535
+
536
+ it("uses the hand-written body verbatim (with renders rewritten)", () => {
537
+ expect(face).toContain("children: block.blocks");
538
+ expect(face).toContain("{% render 'built--components--stack',");
539
+ });
540
+
541
+ it("inlines the face's own css", () => {
542
+ expect(face).toContain(".stack-block {}");
543
+ });
544
+
545
+ it("injects the face schema", () => {
546
+ expect(face).toContain('"name": "Stack"');
547
+ });
548
+ });
549
+
550
+ describe("when a block face owns private child blocks", () => {
551
+ beforeEach(async () => {
552
+ await writeComponent(baseline, "components/tabs", {
553
+ liquid: "<div>tabs</div>",
554
+ });
555
+ });
556
+ beforeEach(async () => {
557
+ await writeFace(baseline, "components/tabs", {
558
+ liquid: "{% render '@/components/tabs', children: block.blocks %}",
559
+ schema: "export const schema = { name: 'Tabs' };",
560
+ });
561
+ });
562
+ beforeEach(async () => {
563
+ await writeComponent(baseline, "components/tabs/block/blocks/tab", {
564
+ liquid: "<div>tab</div>",
565
+ schema: "export const schema = { name: 'Tab' };",
566
+ });
567
+ });
568
+ beforeEach(async () => {
569
+ await build({ layers: [consumer.layer, baseline.layer] });
570
+ });
571
+
572
+ it("emits the child as a private block named through the face", async () => {
573
+ const child = await readFile(
574
+ join(
575
+ consumer.path,
576
+ "blocks/_built--components--tabs--blocks--tab.liquid",
577
+ ),
578
+ "utf-8",
579
+ );
580
+ expect(child).toContain("<div>tab</div>");
581
+ });
582
+
583
+ it("auto-merges the private child into the face's blocks array", async () => {
584
+ const face = await readFile(
585
+ join(consumer.path, "blocks/built--components--tabs.liquid"),
586
+ "utf-8",
587
+ );
588
+ expect(face).toContain('"type": "_built--components--tabs--blocks--tab"');
589
+ });
590
+ });
591
+
592
+ describe("when a `block/` dir sits under a real block (not a snippet)", () => {
593
+ beforeEach(async () => {
594
+ await writeComponent(baseline, "blocks/promo", {
595
+ liquid: "<aside>promo</aside>",
596
+ schema: "export const schema = { name: 'Promo' };",
597
+ });
598
+ });
599
+ beforeEach(async () => {
600
+ // A face marker here would collide with promo's own type:segments key.
601
+ // Faces are components-only, so this must be ignored, not silently drop
602
+ // the block.
603
+ await writeFace(baseline, "blocks/promo", {
604
+ schema: "export const schema = { name: 'ShouldBeIgnored' };",
605
+ });
606
+ });
607
+ beforeEach(async () => {
608
+ await build({ layers: [consumer.layer, baseline.layer] });
609
+ });
610
+
611
+ it("still emits the block, unshadowed by a spurious face", async () => {
612
+ const promo = await readFile(
613
+ join(consumer.path, "blocks/built--blocks--promo.liquid"),
614
+ "utf-8",
615
+ );
616
+ expect(promo).toContain("<aside>promo</aside>");
617
+ expect(promo).toContain('"name": "Promo"');
618
+ expect(promo).not.toContain("ShouldBeIgnored");
619
+ });
620
+ });
439
621
  });
440
622
 
441
623
  async function makeFixture(tempRoot: string, name: string): Promise<Fixture> {
@@ -473,3 +655,28 @@ async function writeComponent(
473
655
  ),
474
656
  );
475
657
  }
658
+
659
+ // Writes a component's block face: files named after the component but located
660
+ // in its `block/` subdir (e.g. src/components/image/block/image.schema.ts).
661
+ async function writeFace(
662
+ fixture: Fixture,
663
+ segments: string,
664
+ slots: { liquid?: string; ts?: string; css?: string; schema?: string },
665
+ ): Promise<void> {
666
+ const parts = segments.split("/");
667
+ const last = parts[parts.length - 1];
668
+ const directory = join(fixture.path, "src", ...parts, "block");
669
+ await mkdir(directory, { recursive: true });
670
+ await Promise.all(
671
+ Object.entries({
672
+ [`${last}.liquid`]: slots.liquid,
673
+ [`${last}.ts`]: slots.ts,
674
+ [`${last}.css`]: slots.css,
675
+ [`${last}.schema.ts`]: slots.schema,
676
+ })
677
+ .filter(([, content]) => content !== undefined)
678
+ .map(([filename, content]) =>
679
+ writeFile(join(directory, filename), content ?? ""),
680
+ ),
681
+ );
682
+ }