@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/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/lib/schema.ts CHANGED
@@ -13,25 +13,23 @@ import type { ThemeBlockSchema } from "./schema-block.generated.ts";
13
13
  import type { SectionSchema } from "./schema-section.generated.ts";
14
14
 
15
15
  /**
16
- * Identity helper for authoring a Shopify section schema. The `const T`
17
- * parameter preserves literal types so the schema author gets autocomplete
18
- * for setting `type` values, `id`s, etc.
16
+ * Identity helper for authoring a Shopify section schema. Typing the parameter
17
+ * as `SectionSchema` directly (rather than a generic `<const T>`) gives the
18
+ * author both autocomplete for `type` values, `id`s, etc. and excess-property
19
+ * checking — a bare type parameter would silently accept unknown keys.
19
20
  *
20
21
  * Use in `<name>.schema.ts` files under `src/sections/`.
21
22
  */
22
- export function defineSchemaSection<const T extends SectionSchema>(
23
- schema: T,
24
- ): T {
23
+ export function defineSchemaSection(schema: SectionSchema): SectionSchema {
25
24
  return schema;
26
25
  }
27
26
 
28
27
  /**
29
- * Identity helper for authoring a Shopify theme-block schema.
28
+ * Identity helper for authoring a Shopify theme-block schema. See
29
+ * {@link defineSchemaSection} for why the parameter is typed directly.
30
30
  *
31
31
  * Use in `<name>.schema.ts` files under `src/blocks/`.
32
32
  */
33
- export function defineSchemaBlock<const T extends ThemeBlockSchema>(
34
- schema: T,
35
- ): T {
33
+ export function defineSchemaBlock(schema: ThemeBlockSchema): ThemeBlockSchema {
36
34
  return schema;
37
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@augeo/smelt",
3
- "version": "1.2.2",
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",