@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.
- package/.github/workflows/verify.yml +23 -10
- package/AGENTS.md +7 -3
- package/README.md +123 -23
- package/dist/cli.mjs +84 -39
- 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/package.json +1 -1
- package/docs/library-conversion-plan.md +0 -419
package/lib/build/build.ts
CHANGED
|
@@ -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
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
85
|
+
const schema = component.schema
|
|
86
|
+
? await loadSchema(component.schema)
|
|
87
|
+
: undefined;
|
|
81
88
|
|
|
82
|
-
|
|
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 (
|
|
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 ${
|
|
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(
|
|
243
|
+
function banner(source: ComponentSource): string {
|
|
211
244
|
return `{%- comment -%}
|
|
212
|
-
GENERATED FROM ${
|
|
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
|
|
218
|
-
const relativePath = relative(
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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.
|
|
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.
|
|
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);
|
package/lib/resolver.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
25
|
+
/** Path segments under `src/` for naming, e.g. `["components", "button"]`. */
|
|
26
26
|
segments: string[];
|
|
27
|
-
/**
|
|
28
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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,
|
|
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 (
|
|
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,
|
|
60
|
-
firstExisting(layers,
|
|
61
|
-
firstExisting(layers,
|
|
62
|
-
firstExisting(layers,
|
|
63
|
-
firstExisting(layers,
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
112
|
-
|
|
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
|
-
? [
|
|
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(
|
|
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
|
-
|
|
218
|
+
dir: string[],
|
|
219
|
+
anchor: string,
|
|
134
220
|
extension: string,
|
|
135
221
|
): Promise<ComponentSource | undefined> {
|
|
136
|
-
const
|
|
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,
|