@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.
@@ -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
+ }
package/lib/resolver.ts CHANGED
@@ -22,48 +22,85 @@ export interface ComponentSource {
22
22
  export interface ResolvedComponent {
23
23
  /** Component kind — determines the output directory. */
24
24
  type: ComponentType;
25
- /** Path segments under `src/`, e.g. `["components", "button"]`. */
25
+ /** Path segments under `src/` for naming, e.g. `["components", "button"]`. */
26
26
  segments: string[];
27
- /** The anchor — every component has a liquid file. */
28
- liquid: ComponentSource;
27
+ /**
28
+ * The anchor liquid. Present for every authored component and for an
29
+ * override block face (`block/<name>.liquid`). A *mechanical* block face —
30
+ * a `block/` dir with only a `<name>.schema.ts` — has no liquid; the engine
31
+ * synthesizes its body from the schema's settings.
32
+ */
33
+ liquid?: ComponentSource;
29
34
  /** Optional sibling slots; each may come from a different layer. */
30
35
  ts?: ComponentSource;
31
36
  css?: ComponentSource;
32
37
  test?: ComponentSource;
33
- /** Section schema authored in TS; executed at build time. */
38
+ /** Section/block schema authored in TS; executed at build time. */
34
39
  schema?: ComponentSource;
40
+ /**
41
+ * True when this component is the block face of another component — emitted
42
+ * from a singular `block/` directory. Its `segments` are the parent's (the
43
+ * `block` marker is zero-segment), but its `type` is `"blocks"`.
44
+ */
45
+ face?: boolean;
35
46
  }
36
47
 
37
48
  const TYPE_NAMES = ["sections", "blocks", "components"] as const;
38
49
  export type ComponentType = (typeof TYPE_NAMES)[number];
39
50
 
51
+ // A singular `block/` directory inside a `src/components/*` snippet declares
52
+ // that snippet's block face (see docs/build-spec.md "Block faces"). It is a
53
+ // *zero-segment* marker: it retargets the type to "blocks" and reroots file
54
+ // lookup inside `block/`, but contributes nothing to the naming segments.
55
+ // Distinct from the plural `blocks/` type marker, which holds private child
56
+ // blocks. Faces are components-only — see the guard in walkType.
57
+ const FACE_MARKER = "block";
58
+
59
+ /**
60
+ * Internal walk result. `dir` is the real path under `src/` (it includes face
61
+ * markers); `segments` is the naming key (it does not). For everything except
62
+ * faces and their descendants the two are identical.
63
+ */
64
+ interface Descriptor {
65
+ type: ComponentType;
66
+ segments: string[];
67
+ dir: string[];
68
+ face: boolean;
69
+ }
70
+
40
71
  export async function resolveLayers(
41
72
  layers: Layer[],
42
73
  ): Promise<ResolvedComponent[]> {
43
74
  const perLayerComponents = await Promise.all(layers.map(walkLayer));
44
- const allKeys = new Map<
45
- string,
46
- { type: ComponentType; segments: string[] }
47
- >();
48
- perLayerComponents.flat().forEach(({ type, segments }) => {
49
- const key = `${type}:${segments.join("/")}`;
75
+ const allKeys = new Map<string, Descriptor>();
76
+ perLayerComponents.flat().forEach((descriptor) => {
77
+ const key = `${descriptor.type}:${descriptor.segments.join("/")}`;
50
78
  if (!allKeys.has(key)) {
51
- allKeys.set(key, { type, segments });
79
+ allKeys.set(key, descriptor);
52
80
  }
53
81
  });
54
82
 
55
83
  const resolved = await Promise.all(
56
84
  Array.from(allKeys.values()).map(
57
- async ({ type, segments }): Promise<ResolvedComponent | undefined> => {
85
+ async (descriptor): Promise<ResolvedComponent | undefined> => {
86
+ const anchor = descriptor.segments[descriptor.segments.length - 1];
58
87
  const [liquid, ts, css, test, schema] = await Promise.all([
59
- firstExisting(layers, segments, ".liquid"),
60
- firstExisting(layers, segments, ".ts"),
61
- firstExisting(layers, segments, ".css"),
62
- firstExisting(layers, segments, ".test.ts"),
63
- firstExisting(layers, segments, ".schema.ts"),
88
+ firstExisting(layers, descriptor.dir, anchor, ".liquid"),
89
+ firstExisting(layers, descriptor.dir, anchor, ".ts"),
90
+ firstExisting(layers, descriptor.dir, anchor, ".css"),
91
+ firstExisting(layers, descriptor.dir, anchor, ".test.ts"),
92
+ firstExisting(layers, descriptor.dir, anchor, ".schema.ts"),
64
93
  ]);
65
- if (!liquid) return undefined;
66
- const component: ResolvedComponent = { type, segments, liquid };
94
+ // A normal component needs a liquid anchor. A block face may instead
95
+ // be anchored on its schema alone (the mechanical case) — the engine
96
+ // generates the wrapper body from it.
97
+ if (!liquid && !(descriptor.face && schema)) return undefined;
98
+ const component: ResolvedComponent = {
99
+ type: descriptor.type,
100
+ segments: descriptor.segments,
101
+ };
102
+ if (descriptor.face) component.face = true;
103
+ if (liquid) component.liquid = liquid;
67
104
  if (ts) component.ts = ts;
68
105
  if (css) component.css = css;
69
106
  if (test) component.test = test;
@@ -78,16 +115,14 @@ export async function resolveLayers(
78
115
  );
79
116
  }
80
117
 
81
- async function walkLayer(
82
- layer: Layer,
83
- ): Promise<Array<{ type: ComponentType; segments: string[] }>> {
118
+ async function walkLayer(layer: Layer): Promise<Descriptor[]> {
84
119
  const sourceRoot = join(layer.path, "src");
85
120
  if (!(await exists(sourceRoot))) return [];
86
121
  const perType = await Promise.all(
87
122
  TYPE_NAMES.map(async (type) => {
88
123
  const typeDir = join(sourceRoot, type);
89
124
  if (!(await exists(typeDir))) return [];
90
- return walkType(typeDir, type, [type]);
125
+ return walkType(typeDir, type, [type], [type]);
91
126
  }),
92
127
  );
93
128
  return perType.flat();
@@ -97,30 +132,80 @@ async function walkType(
97
132
  directory: string,
98
133
  type: ComponentType,
99
134
  segments: string[],
100
- ): Promise<Array<{ type: ComponentType; segments: string[] }>> {
135
+ dir: string[],
136
+ ): Promise<Descriptor[]> {
101
137
  const entries = await readdir(directory, { withFileTypes: true });
102
138
  const directories = entries.filter((entry) => entry.isDirectory());
139
+ // We're "inside a component" (rather than at a type root) when the last
140
+ // segment names a component rather than a type. Only there is `block/` a
141
+ // face marker — at a type root it's just a component literally named "block".
142
+ const inComponentContext = !(TYPE_NAMES as readonly string[]).includes(
143
+ segments[segments.length - 1] ?? "",
144
+ );
103
145
 
104
146
  const nested = await Promise.all(
105
147
  directories.map(async (entry) => {
106
148
  const subdirectory = join(directory, entry.name);
107
149
 
108
- // Nested type marker (e.g. `components/` under a section) shifts type
109
- // and namespace.
150
+ // Nested type marker (e.g. `components/` under a section, or the plural
151
+ // `blocks/` holding private children) shifts type and namespace.
110
152
  if ((TYPE_NAMES as readonly string[]).includes(entry.name)) {
111
- return walkType(subdirectory, entry.name as ComponentType, [
112
- ...segments,
113
- entry.name,
114
- ]);
153
+ return walkType(
154
+ subdirectory,
155
+ entry.name as ComponentType,
156
+ [...segments, entry.name],
157
+ [...dir, entry.name],
158
+ );
159
+ }
160
+
161
+ // Singular `block/` face marker: zero-segment. Retarget type to
162
+ // "blocks" and reroot file lookup inside `block/`, but leave the naming
163
+ // segments alone. Empty `block/` dirs resolve to nothing and are dropped.
164
+ // Only snippets (`type === "components"`) get a face — a section or
165
+ // block sprouting one would share its parent's `type:segments` key and
166
+ // collide. (At a type root `inComponentContext` is false, so this can't
167
+ // fire on a top-level component literally named "block".)
168
+ if (
169
+ entry.name === FACE_MARKER &&
170
+ inComponentContext &&
171
+ type === "components"
172
+ ) {
173
+ const face: Descriptor = {
174
+ type: "blocks",
175
+ segments: [...segments],
176
+ dir: [...dir, entry.name],
177
+ face: true,
178
+ };
179
+ const deeper = await walkType(
180
+ subdirectory,
181
+ type,
182
+ [...segments],
183
+ [...dir, entry.name],
184
+ );
185
+ return [face, ...deeper];
115
186
  }
116
187
 
188
+ // Nested component.
117
189
  const componentSegments = [...segments, entry.name];
190
+ const componentDir = [...dir, entry.name];
118
191
  const liquidPath = join(subdirectory, `${entry.name}.liquid`);
119
- const here = (await exists(liquidPath))
120
- ? [{ type, segments: componentSegments }]
192
+ const here: Descriptor[] = (await exists(liquidPath))
193
+ ? [
194
+ {
195
+ type,
196
+ segments: componentSegments,
197
+ dir: componentDir,
198
+ face: false,
199
+ },
200
+ ]
121
201
  : [];
122
202
 
123
- const deeper = await walkType(subdirectory, type, componentSegments);
203
+ const deeper = await walkType(
204
+ subdirectory,
205
+ type,
206
+ componentSegments,
207
+ componentDir,
208
+ );
124
209
  return [...here, ...deeper];
125
210
  }),
126
211
  );
@@ -130,11 +215,11 @@ async function walkType(
130
215
 
131
216
  async function firstExisting(
132
217
  layers: Layer[],
133
- segments: string[],
218
+ dir: string[],
219
+ anchor: string,
134
220
  extension: string,
135
221
  ): Promise<ComponentSource | undefined> {
136
- const last = segments[segments.length - 1];
137
- const relativePath = join("src", ...segments, `${last}${extension}`);
222
+ const relativePath = join("src", ...dir, `${anchor}${extension}`);
138
223
  const candidates = await Promise.all(
139
224
  layers.map(async (layer) => ({
140
225
  layer,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@augeo/smelt",
3
- "version": "1.2.3",
3
+ "version": "1.3.0",
4
4
  "description": "Library + CLI for building Shopify themes from a colocated component tree",
5
5
  "license": "MIT",
6
6
  "type": "module",