@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.
@@ -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
+ }
@@ -43,8 +43,15 @@ export async function build(context: BuildContext): Promise<number> {
43
43
  const components = await resolveLayers(context.layers);
44
44
  await cleanBuiltOutputs(outputRoot);
45
45
 
46
+ // Keyed by segments alone. A block face shares its snippet's segments (the
47
+ // `block/` marker is zero-segment), so faces are excluded here — they are
48
+ // never a render target (you render the underlying snippet) nor a private
49
+ // child, so neither consumer of this map needs them, and dropping them keeps
50
+ // the remaining keys unique.
46
51
  const lookup = new Map(
47
- components.map((component) => [component.segments.join("/"), component]),
52
+ components
53
+ .filter((component) => !component.face)
54
+ .map((component) => [component.segments.join("/"), component]),
48
55
  );
49
56
  await Promise.all(
50
57
  components.map((component) =>
@@ -75,14 +82,40 @@ async function buildComponent(
75
82
  lookup: Map<string, ResolvedComponent>,
76
83
  outputRoot: string,
77
84
  ): Promise<void> {
78
- const source = await readFile(component.liquid.path, "utf-8");
79
- assertNoInlineSchema(source, component);
80
- const body = rewriteRenders(source, component, lookup);
85
+ const schema = component.schema
86
+ ? await loadSchema(component.schema)
87
+ : undefined;
81
88
 
82
- const sections = [banner(component), body.trimEnd()];
89
+ let body: string;
90
+ if (component.liquid) {
91
+ const source = await readFile(component.liquid.path, "utf-8");
92
+ assertNoInlineSchema(source, component);
93
+ body = rewriteRenders(source, component, lookup);
94
+ } else {
95
+ // Mechanical block face: no hand-written body, so synthesize the wrapper
96
+ // render from the schema's settings (see docs/build-spec.md "Block faces").
97
+ // A mechanical face renders its underlying snippet — fail clearly here if
98
+ // that snippet is missing, rather than deferring to a generic
99
+ // "Cannot resolve render" from the alias rewrite.
100
+ const snippetKey = component.segments.join("/");
101
+ if (!lookup.has(snippetKey)) {
102
+ const name = component.segments[component.segments.length - 1];
103
+ throw new Error(
104
+ `Block face '${snippetKey}' has no underlying snippet to render — ` +
105
+ `a mechanical face wraps its component. Add ${name}.liquid, or ` +
106
+ `author a block/${name}.liquid override.`,
107
+ );
108
+ }
109
+ body = rewriteRenders(
110
+ generateFaceBody(component, schema),
111
+ component,
112
+ lookup,
113
+ );
114
+ }
115
+
116
+ const sections = [banner(anchorSourceOf(component)), body.trimEnd()];
83
117
 
84
- if (component.schema) {
85
- const schema = await loadSchema(component.schema);
118
+ if (schema !== undefined) {
86
119
  const merged = mergePrivateBlocks(schema, component, lookup);
87
120
  sections.push(renderSchemaBlock(merged));
88
121
  }
@@ -114,7 +147,7 @@ function assertNoInlineSchema(
114
147
  if (!/\{%-?\s*schema\s*-?%\}/.test(source)) return;
115
148
  const last = component.segments[component.segments.length - 1];
116
149
  throw new Error(
117
- `Inline {% schema %} block in ${sourcePathForComponent(component)}. ` +
150
+ `Inline {% schema %} block in ${sourcePathFor(anchorSourceOf(component))}. ` +
118
151
  `Schemas must live in a sibling ${last}.schema.ts file — import ` +
119
152
  `{ defineSchemaSection } (or defineSchemaBlock) from "@augeo/smelt/schema".`,
120
153
  );
@@ -207,19 +240,60 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
207
240
  return typeof value === "object" && value !== null && !Array.isArray(value);
208
241
  }
209
242
 
210
- function banner(component: ResolvedComponent): string {
243
+ function banner(source: ComponentSource): string {
211
244
  return `{%- comment -%}
212
- GENERATED FROM ${sourcePathForComponent(component)} — do not edit this file directly.
245
+ GENERATED FROM ${sourcePathFor(source)} — do not edit this file directly.
213
246
  Edit the source and run \`npm run build\`.
214
247
  {%- endcomment -%}`;
215
248
  }
216
249
 
217
- function sourcePathForComponent(component: ResolvedComponent): string {
218
- const relativePath = relative(
219
- component.liquid.layer.path,
220
- component.liquid.path,
221
- );
222
- return `${component.liquid.layer.name}/${relativePath}`;
250
+ function sourcePathFor(source: ComponentSource): string {
251
+ const relativePath = relative(source.layer.path, source.path);
252
+ return `${source.layer.name}/${relativePath}`;
253
+ }
254
+
255
+ /**
256
+ * The slot a built file traces back to: the hand-written liquid when present,
257
+ * else the schema (a mechanical block face is anchored on its schema alone).
258
+ */
259
+ function anchorSourceOf(component: ResolvedComponent): ComponentSource {
260
+ const anchor = component.liquid ?? component.schema;
261
+ if (!anchor) {
262
+ throw new Error(
263
+ `Component '${component.segments.join("/")}' has neither a liquid nor a schema file.`,
264
+ );
265
+ }
266
+ return anchor;
267
+ }
268
+
269
+ /**
270
+ * Synthesize a block face's wrapper body: render the underlying component,
271
+ * mapping each schema setting to a render arg of the same name (`id == arg`)
272
+ * and passing `shopify_attributes` through. The `@/...` render is rewritten to
273
+ * the built name by the caller. For non-mechanical wrappers (derived props,
274
+ * `block.blocks`, …) author a `block/<name>.liquid` override instead.
275
+ */
276
+ function generateFaceBody(
277
+ component: ResolvedComponent,
278
+ schema: unknown,
279
+ ): string {
280
+ const settings =
281
+ isPlainObject(schema) && Array.isArray(schema.settings)
282
+ ? schema.settings
283
+ : [];
284
+ const settingArgs = settings
285
+ .filter(
286
+ (setting): setting is { id: string } =>
287
+ isPlainObject(setting) && typeof setting.id === "string",
288
+ )
289
+ .map((setting) => `\t${setting.id}: block.settings.${setting.id},`);
290
+ const target = `@/${component.segments.join("/")}`;
291
+ return [
292
+ `{% render '${target}',`,
293
+ ...settingArgs,
294
+ "\tshopify_attributes: block.shopify_attributes",
295
+ "%}",
296
+ ].join("\n");
223
297
  }
224
298
 
225
299
  function wrapEmbedded(tag: "stylesheet" | "javascript", body: string): string {
@@ -244,7 +318,7 @@ function rewriteRenders(
244
318
  const target = lookup.get(lookupKey);
245
319
  if (!target) {
246
320
  throw new Error(
247
- `Cannot resolve render '${importPath}' (looked up '${lookupKey}') from ${component.liquid.path}`,
321
+ `Cannot resolve render '${importPath}' (looked up '${lookupKey}') from ${anchorSourceOf(component).path}`,
248
322
  );
249
323
  }
250
324
  return `${prefix}${builtNameOf(target)}${suffix}`;
@@ -263,7 +337,7 @@ function resolveImport(
263
337
  // `../` is intentionally not supported — see docs/build-spec.md.
264
338
  if (importPath.includes("../")) {
265
339
  throw new Error(
266
- `'../' is not supported in render path: '${importPath}' (in ${component.liquid.path})`,
340
+ `'../' is not supported in render path: '${importPath}' (in ${anchorSourceOf(component).path})`,
267
341
  );
268
342
  }
269
343
  const remaining = importPath.slice(2).split("/").filter(Boolean);
@@ -52,7 +52,7 @@ describe("resolveLayers()", () => {
52
52
  });
53
53
 
54
54
  it("anchors the liquid slot against the baseline", () => {
55
- expect(resolved[0].liquid.layer).toBe(baseline.layer);
55
+ expect(resolved[0].liquid?.layer).toBe(baseline.layer);
56
56
  });
57
57
 
58
58
  it("provides the ts slot from the baseline", () => {
@@ -97,6 +97,70 @@ describe("resolveLayers()", () => {
97
97
  });
98
98
  });
99
99
 
100
+ describe("with a component that has a block/ face", () => {
101
+ beforeEach(async () => {
102
+ await writeComponent(baseline, "components/image", {
103
+ liquid: "<img>",
104
+ });
105
+ await writeFace(baseline, "components/image", {
106
+ schema: "export const schema = { name: 'Image' };",
107
+ });
108
+ });
109
+
110
+ let resolved: ResolvedComponent[];
111
+ beforeEach(async () => {
112
+ resolved = await resolveLayers([baseline.layer]);
113
+ });
114
+
115
+ it("resolves the snippet and the face as two components", () => {
116
+ expect(resolved).toHaveLength(2);
117
+ });
118
+
119
+ it("emits the face as a blocks-typed component with the parent's segments", () => {
120
+ const face = resolved.find((component) => component.face);
121
+ expect(face?.type).toBe("blocks");
122
+ expect(face?.segments).toEqual(["components", "image"]);
123
+ });
124
+
125
+ it("anchors the mechanical face on its schema, not a liquid", () => {
126
+ const face = resolved.find((component) => component.face);
127
+ expect(face?.liquid).toBeUndefined();
128
+ expect(face?.schema?.layer).toBe(baseline.layer);
129
+ });
130
+
131
+ it("does not flag the underlying snippet as a face", () => {
132
+ const snippet = resolved.find(
133
+ (component) => component.type === "components",
134
+ );
135
+ expect(snippet?.face).toBeUndefined();
136
+ });
137
+ });
138
+
139
+ describe("with a block/ dir under a section (not a snippet)", () => {
140
+ beforeEach(async () => {
141
+ await writeComponent(baseline, "sections/hero", {
142
+ liquid: "<section></section>",
143
+ });
144
+ await writeFace(baseline, "sections/hero", {
145
+ schema: "export const schema = { name: 'Nope' };",
146
+ });
147
+ });
148
+
149
+ let resolved: ResolvedComponent[];
150
+ beforeEach(async () => {
151
+ resolved = await resolveLayers([baseline.layer]);
152
+ });
153
+
154
+ it("does not produce a face (faces are components-only)", () => {
155
+ expect(resolved.some((component) => component.face)).toBe(false);
156
+ });
157
+
158
+ it("leaves the section as the only resolved component", () => {
159
+ expect(resolved).toHaveLength(1);
160
+ expect(resolved[0].type).toBe("sections");
161
+ });
162
+ });
163
+
100
164
  describe("with a consumer layer ahead of the baseline", () => {
101
165
  describe("when the consumer shadows only the css slot", () => {
102
166
  beforeEach(async () => {
@@ -119,7 +183,7 @@ describe("resolveLayers()", () => {
119
183
  });
120
184
 
121
185
  it("takes the liquid slot from the baseline", () => {
122
- expect(button.liquid.layer).toBe(baseline.layer);
186
+ expect(button.liquid?.layer).toBe(baseline.layer);
123
187
  });
124
188
 
125
189
  it("takes the ts slot from the baseline", () => {
@@ -153,7 +217,7 @@ describe("resolveLayers()", () => {
153
217
  });
154
218
 
155
219
  it("takes the liquid slot from the consumer", () => {
156
- expect(button.liquid.layer).toBe(consumer.layer);
220
+ expect(button.liquid?.layer).toBe(consumer.layer);
157
221
  });
158
222
 
159
223
  it("takes the ts slot from the consumer", () => {
@@ -189,7 +253,7 @@ describe("resolveLayers()", () => {
189
253
  });
190
254
 
191
255
  it("still anchors the liquid slot against the baseline", () => {
192
- expect(button.liquid.layer).toBe(baseline.layer);
256
+ expect(button.liquid?.layer).toBe(baseline.layer);
193
257
  });
194
258
  });
195
259
 
@@ -273,3 +337,26 @@ async function writeComponent(
273
337
  ),
274
338
  );
275
339
  }
340
+
341
+ // Writes a block face: files named after the component but in its `block/`
342
+ // subdir (e.g. src/components/image/block/image.schema.ts).
343
+ async function writeFace(
344
+ fixture: Fixture,
345
+ segments: string,
346
+ slots: { liquid?: string; schema?: string },
347
+ ): Promise<void> {
348
+ const parts = segments.split("/");
349
+ const last = parts[parts.length - 1];
350
+ const directory = join(fixture.path, "src", ...parts, "block");
351
+ await mkdir(directory, { recursive: true });
352
+ await Promise.all(
353
+ Object.entries({
354
+ [`${last}.liquid`]: slots.liquid,
355
+ [`${last}.schema.ts`]: slots.schema,
356
+ })
357
+ .filter(([, content]) => content !== undefined)
358
+ .map(([filename, content]) =>
359
+ writeFile(join(directory, filename), content ?? ""),
360
+ ),
361
+ );
362
+ }