@augeo/smelt 1.2.2

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.
Files changed (152) hide show
  1. package/.claude/settings.json +11 -0
  2. package/.github/workflows/verify.yml +64 -0
  3. package/.gitmodules +3 -0
  4. package/.prettierignore +25 -0
  5. package/.prettierrc.cjs +9 -0
  6. package/.zed/settings.json +21 -0
  7. package/AGENTS.md +232 -0
  8. package/LICENSE +21 -0
  9. package/README.md +266 -0
  10. package/biome.json +58 -0
  11. package/dist/cli.d.mts +1 -0
  12. package/dist/cli.mjs +350 -0
  13. package/dist/schema.d.mts +265 -0
  14. package/dist/schema.mjs +21 -0
  15. package/docs/TESTING.md +293 -0
  16. package/docs/assets-plan.md +197 -0
  17. package/docs/build-spec.md +466 -0
  18. package/docs/library-conversion-plan.md +419 -0
  19. package/example/.gitattributes +7 -0
  20. package/example/.shopifyignore +28 -0
  21. package/example/.theme-check.yml +7 -0
  22. package/example/blocks/_built--sections--hero--blocks--feature.liquid +52 -0
  23. package/example/config/settings_schema.json +10 -0
  24. package/example/layout/theme.liquid +25 -0
  25. package/example/locales/en.default.json +1 -0
  26. package/example/package-lock.json +51 -0
  27. package/example/package.json +20 -0
  28. package/example/sections/built--sections--hero.liquid +83 -0
  29. package/example/snippets/built--components--button.liquid +38 -0
  30. package/example/snippets/built--components--card.liquid +33 -0
  31. package/example/src/components/button/button.css +13 -0
  32. package/example/src/components/card/card.css +16 -0
  33. package/example/src/components/card/card.liquid +9 -0
  34. package/example/src/sections/hero/blocks/feature/feature.css +11 -0
  35. package/example/src/sections/hero/blocks/feature/feature.liquid +9 -0
  36. package/example/src/sections/hero/blocks/feature/feature.schema.ts +14 -0
  37. package/example/src/sections/hero/hero.css +15 -0
  38. package/example/src/sections/hero/hero.liquid +16 -0
  39. package/example/src/sections/hero/hero.schema.ts +26 -0
  40. package/example/src/sections/hero/hero.test.ts +43 -0
  41. package/example/src/utilities/labels.ts +5 -0
  42. package/example/templates/index.liquid +1 -0
  43. package/example/tsconfig.json +10 -0
  44. package/example/vitest.config.ts +6 -0
  45. package/lib/build/build.test.ts +475 -0
  46. package/lib/build/build.ts +314 -0
  47. package/lib/build/command.ts +27 -0
  48. package/lib/build/index.ts +1 -0
  49. package/lib/cli.ts +17 -0
  50. package/lib/dev/command.ts +25 -0
  51. package/lib/dev/index.ts +1 -0
  52. package/lib/dev/watch.ts +52 -0
  53. package/lib/resolver.test.ts +275 -0
  54. package/lib/resolver.ts +156 -0
  55. package/lib/schema.ts +37 -0
  56. package/package.json +59 -0
  57. package/scripts/codegen-schema.ts +66 -0
  58. package/src/components/button/button.css +13 -0
  59. package/src/components/button/button.liquid +5 -0
  60. package/src/components/button/button.ts +5 -0
  61. package/src/tsconfig.json +10 -0
  62. package/tests/example.test.ts +101 -0
  63. package/tsconfig.json +20 -0
  64. package/tsdown.config.ts +14 -0
  65. package/vendor/theme-liquid-docs/.gitattributes +10 -0
  66. package/vendor/theme-liquid-docs/.github/CODEOWNERS +1 -0
  67. package/vendor/theme-liquid-docs/.github/CODE_OF_CONDUCT.md +73 -0
  68. package/vendor/theme-liquid-docs/.github/ISSUE_TEMPLATE/bug_report.md +17 -0
  69. package/vendor/theme-liquid-docs/.github/ISSUE_TEMPLATE/feature_request.md +17 -0
  70. package/vendor/theme-liquid-docs/.github/dependabot.yaml +6 -0
  71. package/vendor/theme-liquid-docs/.github/workflows/ci.yml +33 -0
  72. package/vendor/theme-liquid-docs/.github/workflows/cla.yml +27 -0
  73. package/vendor/theme-liquid-docs/.github/workflows/shopify-dev-preview-automation.yml +86 -0
  74. package/vendor/theme-liquid-docs/.github/workflows/update-latest.yml +56 -0
  75. package/vendor/theme-liquid-docs/.prettierrc.json +16 -0
  76. package/vendor/theme-liquid-docs/.vscode/settings.json +28 -0
  77. package/vendor/theme-liquid-docs/LICENSE.md +7 -0
  78. package/vendor/theme-liquid-docs/README.md +48 -0
  79. package/vendor/theme-liquid-docs/ai/claude/CLAUDE.md +1485 -0
  80. package/vendor/theme-liquid-docs/ai/cursor/rules/assets.mdc +15 -0
  81. package/vendor/theme-liquid-docs/ai/cursor/rules/blocks.mdc +339 -0
  82. package/vendor/theme-liquid-docs/ai/cursor/rules/examples/block-example-group.mdc +103 -0
  83. package/vendor/theme-liquid-docs/ai/cursor/rules/examples/block-example-text.mdc +59 -0
  84. package/vendor/theme-liquid-docs/ai/cursor/rules/examples/section-example.mdc +61 -0
  85. package/vendor/theme-liquid-docs/ai/cursor/rules/examples/snippet-example.mdc +72 -0
  86. package/vendor/theme-liquid-docs/ai/cursor/rules/liquid.mdc +837 -0
  87. package/vendor/theme-liquid-docs/ai/cursor/rules/locales.mdc +100 -0
  88. package/vendor/theme-liquid-docs/ai/cursor/rules/localization.mdc +67 -0
  89. package/vendor/theme-liquid-docs/ai/cursor/rules/mcp.mdc +2 -0
  90. package/vendor/theme-liquid-docs/ai/cursor/rules/schemas.mdc +184 -0
  91. package/vendor/theme-liquid-docs/ai/cursor/rules/sections.mdc +84 -0
  92. package/vendor/theme-liquid-docs/ai/cursor/rules/settings-schema.mdc +51 -0
  93. package/vendor/theme-liquid-docs/ai/cursor/rules/snippets.mdc +119 -0
  94. package/vendor/theme-liquid-docs/ai/github/copilot-instructions.md +1485 -0
  95. package/vendor/theme-liquid-docs/ai/liquid.mdc +638 -0
  96. package/vendor/theme-liquid-docs/data/filters.json +6148 -0
  97. package/vendor/theme-liquid-docs/data/latest.json +2 -0
  98. package/vendor/theme-liquid-docs/data/objects.json +20594 -0
  99. package/vendor/theme-liquid-docs/data/shopify_system_translations.json +2586 -0
  100. package/vendor/theme-liquid-docs/data/tags.json +1276 -0
  101. package/vendor/theme-liquid-docs/package.json +20 -0
  102. package/vendor/theme-liquid-docs/schemas/manifest_schema.json +31 -0
  103. package/vendor/theme-liquid-docs/schemas/manifest_theme.json +19 -0
  104. package/vendor/theme-liquid-docs/schemas/manifest_theme_app_extension.json +10 -0
  105. package/vendor/theme-liquid-docs/schemas/theme/app_block_entry.json +13 -0
  106. package/vendor/theme-liquid-docs/schemas/theme/default_setting_values.json +24 -0
  107. package/vendor/theme-liquid-docs/schemas/theme/local_block_entry.json +25 -0
  108. package/vendor/theme-liquid-docs/schemas/theme/preset.json +72 -0
  109. package/vendor/theme-liquid-docs/schemas/theme/preset_blocks.json +91 -0
  110. package/vendor/theme-liquid-docs/schemas/theme/section.json +208 -0
  111. package/vendor/theme-liquid-docs/schemas/theme/setting.json +1413 -0
  112. package/vendor/theme-liquid-docs/schemas/theme/settings.json +10 -0
  113. package/vendor/theme-liquid-docs/schemas/theme/targetted_block_entry.json +15 -0
  114. package/vendor/theme-liquid-docs/schemas/theme/theme_block.json +91 -0
  115. package/vendor/theme-liquid-docs/schemas/theme/theme_block_entry.json +14 -0
  116. package/vendor/theme-liquid-docs/schemas/theme/theme_settings.json +83 -0
  117. package/vendor/theme-liquid-docs/schemas/theme/translations.json +63 -0
  118. package/vendor/theme-liquid-docs/schemas/update/update_extension_schema_v1.json +186 -0
  119. package/vendor/theme-liquid-docs/tests/fixtures/section-nested-blocks.json +18 -0
  120. package/vendor/theme-liquid-docs/tests/fixtures/section-schema-1.json +90 -0
  121. package/vendor/theme-liquid-docs/tests/fixtures/section-schema-2.json +201 -0
  122. package/vendor/theme-liquid-docs/tests/fixtures/section-schema-3.json +29 -0
  123. package/vendor/theme-liquid-docs/tests/fixtures/section-schema-4.json +315 -0
  124. package/vendor/theme-liquid-docs/tests/fixtures/section-schema-5.json +114 -0
  125. package/vendor/theme-liquid-docs/tests/fixtures/section-schema-6.json +63 -0
  126. package/vendor/theme-liquid-docs/tests/fixtures/section-schema-conditional-settings.json +145 -0
  127. package/vendor/theme-liquid-docs/tests/fixtures/section-schema-preset-blocks-as-hash.json +60 -0
  128. package/vendor/theme-liquid-docs/tests/fixtures/section-schema-static-block-preset.json +76 -0
  129. package/vendor/theme-liquid-docs/tests/fixtures/section-settings.json +34 -0
  130. package/vendor/theme-liquid-docs/tests/fixtures/theme-block-1.json +234 -0
  131. package/vendor/theme-liquid-docs/tests/fixtures/theme-block-2.json +253 -0
  132. package/vendor/theme-liquid-docs/tests/fixtures/theme-block-basics.json +48 -0
  133. package/vendor/theme-liquid-docs/tests/fixtures/theme-block-conditional-settings.json +202 -0
  134. package/vendor/theme-liquid-docs/tests/fixtures/theme-block-presets-as-hash.json +50 -0
  135. package/vendor/theme-liquid-docs/tests/fixtures/theme-block-settings.json +34 -0
  136. package/vendor/theme-liquid-docs/tests/fixtures/theme-settings-all-settings.json +313 -0
  137. package/vendor/theme-liquid-docs/tests/fixtures/theme-settings-dawn.json +1469 -0
  138. package/vendor/theme-liquid-docs/tests/fixtures/theme-settings-metadata.json +10 -0
  139. package/vendor/theme-liquid-docs/tests/fixtures/translations-1.json +14 -0
  140. package/vendor/theme-liquid-docs/tests/section.spec.ts +367 -0
  141. package/vendor/theme-liquid-docs/tests/test-constants.ts +58 -0
  142. package/vendor/theme-liquid-docs/tests/test-helpers.ts +104 -0
  143. package/vendor/theme-liquid-docs/tests/theme-settings/color_palette.spec.ts +184 -0
  144. package/vendor/theme-liquid-docs/tests/theme-settings/color_scheme_group.spec.ts +143 -0
  145. package/vendor/theme-liquid-docs/tests/theme-settings/general.spec.ts +192 -0
  146. package/vendor/theme-liquid-docs/tests/theme-settings/metaobject.spec.ts +94 -0
  147. package/vendor/theme-liquid-docs/tests/theme-settings/resource_list.spec.ts +58 -0
  148. package/vendor/theme-liquid-docs/tests/theme-settings/theme-metadata.spec.ts +59 -0
  149. package/vendor/theme-liquid-docs/tests/theme_block.spec.ts +266 -0
  150. package/vendor/theme-liquid-docs/tests/translations_schema.spec.ts +31 -0
  151. package/vendor/theme-liquid-docs/yarn.lock +543 -0
  152. package/vitest.config.ts +7 -0
@@ -0,0 +1,275 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import {
6
+ type Layer,
7
+ type ResolvedComponent,
8
+ resolveLayers,
9
+ } from "./resolver.ts";
10
+
11
+ interface Fixture {
12
+ path: string;
13
+ layer: Layer;
14
+ }
15
+
16
+ describe("resolveLayers()", () => {
17
+ let tempRoot: string;
18
+ beforeEach(async () => {
19
+ tempRoot = await mkdtemp(join(tmpdir(), "smelt-resolver-"));
20
+ });
21
+
22
+ let baseline: Fixture;
23
+ beforeEach(async () => {
24
+ baseline = await makeFixture(tempRoot, "baseline");
25
+ });
26
+
27
+ let consumer: Fixture;
28
+ beforeEach(async () => {
29
+ consumer = await makeFixture(tempRoot, "consumer");
30
+ });
31
+
32
+ afterEach(async () => {
33
+ await rm(tempRoot, { recursive: true, force: true });
34
+ });
35
+
36
+ describe("with a baseline layer only", () => {
37
+ beforeEach(async () => {
38
+ await writeComponent(baseline, "components/button", {
39
+ liquid: "<button></button>",
40
+ ts: "console.log('hi')",
41
+ css: ".btn {}",
42
+ });
43
+ });
44
+
45
+ let resolved: ResolvedComponent[];
46
+ beforeEach(async () => {
47
+ resolved = await resolveLayers([baseline.layer]);
48
+ });
49
+
50
+ it("returns the component", () => {
51
+ expect(resolved).toHaveLength(1);
52
+ });
53
+
54
+ it("anchors the liquid slot against the baseline", () => {
55
+ expect(resolved[0].liquid.layer).toBe(baseline.layer);
56
+ });
57
+
58
+ it("provides the ts slot from the baseline", () => {
59
+ expect(resolved[0].ts?.layer).toBe(baseline.layer);
60
+ });
61
+
62
+ it("provides the css slot from the baseline", () => {
63
+ expect(resolved[0].css?.layer).toBe(baseline.layer);
64
+ });
65
+
66
+ it("leaves the test slot empty when no test file exists", () => {
67
+ expect(resolved[0].test).toBeUndefined();
68
+ });
69
+
70
+ it("leaves the schema slot empty when no schema file exists", () => {
71
+ expect(resolved[0].schema).toBeUndefined();
72
+ });
73
+ });
74
+
75
+ describe("with a component that has a sibling .schema.ts file", () => {
76
+ beforeEach(async () => {
77
+ await writeComponent(baseline, "sections/hero", {
78
+ liquid: "<section></section>",
79
+ schema: "export const schema = { name: 'Hero' };",
80
+ });
81
+ });
82
+
83
+ let hero: ResolvedComponent;
84
+ beforeEach(async () => {
85
+ const resolved = await resolveLayers([baseline.layer]);
86
+ hero = resolved[0];
87
+ });
88
+
89
+ it("picks up the schema slot", () => {
90
+ expect(hero.schema?.layer).toBe(baseline.layer);
91
+ });
92
+
93
+ it("does not put the schema file on the ts slot", () => {
94
+ // hero.ts and hero.schema.ts are distinct filenames; only the former
95
+ // would land in the ts slot.
96
+ expect(hero.ts).toBeUndefined();
97
+ });
98
+ });
99
+
100
+ describe("with a consumer layer ahead of the baseline", () => {
101
+ describe("when the consumer shadows only the css slot", () => {
102
+ beforeEach(async () => {
103
+ await writeComponent(baseline, "components/button", {
104
+ liquid: "<button></button>",
105
+ ts: "console.log('hi')",
106
+ css: ".btn { color: black; }",
107
+ });
108
+ });
109
+ beforeEach(async () => {
110
+ await writeComponent(consumer, "components/button", {
111
+ css: ".btn { color: yellow; }",
112
+ });
113
+ });
114
+
115
+ let button: ResolvedComponent;
116
+ beforeEach(async () => {
117
+ const resolved = await resolveLayers([consumer.layer, baseline.layer]);
118
+ button = resolved[0];
119
+ });
120
+
121
+ it("takes the liquid slot from the baseline", () => {
122
+ expect(button.liquid.layer).toBe(baseline.layer);
123
+ });
124
+
125
+ it("takes the ts slot from the baseline", () => {
126
+ expect(button.ts?.layer).toBe(baseline.layer);
127
+ });
128
+
129
+ it("takes the css slot from the consumer", () => {
130
+ expect(button.css?.layer).toBe(consumer.layer);
131
+ });
132
+ });
133
+
134
+ describe("when the consumer fully shadows the baseline", () => {
135
+ beforeEach(async () => {
136
+ await writeComponent(baseline, "components/button", {
137
+ liquid: "<button>baseline</button>",
138
+ css: ".btn {}",
139
+ });
140
+ });
141
+ beforeEach(async () => {
142
+ await writeComponent(consumer, "components/button", {
143
+ liquid: "<button>consumer</button>",
144
+ ts: "console.log('consumer')",
145
+ css: ".btn {}",
146
+ });
147
+ });
148
+
149
+ let button: ResolvedComponent;
150
+ beforeEach(async () => {
151
+ const resolved = await resolveLayers([consumer.layer, baseline.layer]);
152
+ button = resolved[0];
153
+ });
154
+
155
+ it("takes the liquid slot from the consumer", () => {
156
+ expect(button.liquid.layer).toBe(consumer.layer);
157
+ });
158
+
159
+ it("takes the ts slot from the consumer", () => {
160
+ expect(button.ts?.layer).toBe(consumer.layer);
161
+ });
162
+
163
+ it("takes the css slot from the consumer", () => {
164
+ expect(button.css?.layer).toBe(consumer.layer);
165
+ });
166
+ });
167
+
168
+ describe("when the consumer adds a slot the baseline lacks", () => {
169
+ beforeEach(async () => {
170
+ await writeComponent(baseline, "components/button", {
171
+ liquid: "<button></button>",
172
+ ts: "console.log('baseline ts')",
173
+ });
174
+ });
175
+ beforeEach(async () => {
176
+ await writeComponent(consumer, "components/button", {
177
+ ts: "console.log('consumer ts')",
178
+ });
179
+ });
180
+
181
+ let button: ResolvedComponent;
182
+ beforeEach(async () => {
183
+ const resolved = await resolveLayers([consumer.layer, baseline.layer]);
184
+ button = resolved[0];
185
+ });
186
+
187
+ it("uses the consumer's ts slot", () => {
188
+ expect(button.ts?.layer).toBe(consumer.layer);
189
+ });
190
+
191
+ it("still anchors the liquid slot against the baseline", () => {
192
+ expect(button.liquid.layer).toBe(baseline.layer);
193
+ });
194
+ });
195
+
196
+ describe("with disjoint components in each layer", () => {
197
+ beforeEach(async () => {
198
+ await writeComponent(baseline, "components/button", {
199
+ liquid: "<button></button>",
200
+ });
201
+ });
202
+ beforeEach(async () => {
203
+ await writeComponent(consumer, "components/icon", {
204
+ liquid: "<svg></svg>",
205
+ });
206
+ });
207
+
208
+ let segmentKeys: string[];
209
+ beforeEach(async () => {
210
+ const resolved = await resolveLayers([consumer.layer, baseline.layer]);
211
+ segmentKeys = resolved
212
+ .map((component) => component.segments.join("/"))
213
+ .sort();
214
+ });
215
+
216
+ it("includes both components in the merged result", () => {
217
+ expect(segmentKeys).toEqual(["components/button", "components/icon"]);
218
+ });
219
+ });
220
+
221
+ describe("when a slot has no liquid anchor in any layer", () => {
222
+ beforeEach(async () => {
223
+ // Consumer has button.css but neither layer has button.liquid.
224
+ await writeComponent(consumer, "components/button", {
225
+ css: ".btn {}",
226
+ });
227
+ });
228
+
229
+ let resolved: ResolvedComponent[];
230
+ beforeEach(async () => {
231
+ resolved = await resolveLayers([consumer.layer, baseline.layer]);
232
+ });
233
+
234
+ it("treats the orphan slot as dead code", () => {
235
+ expect(resolved).toHaveLength(0);
236
+ });
237
+ });
238
+ });
239
+ });
240
+
241
+ async function makeFixture(tempRoot: string, name: string): Promise<Fixture> {
242
+ const path = join(tempRoot, name);
243
+ await mkdir(join(path, "src"), { recursive: true });
244
+ return { path, layer: { path, name } };
245
+ }
246
+
247
+ async function writeComponent(
248
+ fixture: Fixture,
249
+ segments: string,
250
+ slots: {
251
+ liquid?: string;
252
+ ts?: string;
253
+ css?: string;
254
+ test?: string;
255
+ schema?: string;
256
+ },
257
+ ): Promise<void> {
258
+ const parts = segments.split("/");
259
+ const last = parts[parts.length - 1];
260
+ const directory = join(fixture.path, "src", ...parts);
261
+ await mkdir(directory, { recursive: true });
262
+ await Promise.all(
263
+ Object.entries({
264
+ [`${last}.liquid`]: slots.liquid,
265
+ [`${last}.ts`]: slots.ts,
266
+ [`${last}.css`]: slots.css,
267
+ [`${last}.test.ts`]: slots.test,
268
+ [`${last}.schema.ts`]: slots.schema,
269
+ })
270
+ .filter(([, content]) => content !== undefined)
271
+ .map(([filename, content]) =>
272
+ writeFile(join(directory, filename), content ?? ""),
273
+ ),
274
+ );
275
+ }
@@ -0,0 +1,156 @@
1
+ // Walks N source layers and produces an ordered list of resolved components:
2
+ // per-file shadowing, first layer wins. See docs/build-spec.md for the layer
3
+ // model.
4
+
5
+ import { readdir, stat } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+
8
+ export interface Layer {
9
+ /** Absolute path to the layer's root — the dir that contains `src/`. */
10
+ path: string;
11
+ /** Display label for diagnostics (e.g. "consumer", "@augeo/smelt"). */
12
+ name: string;
13
+ }
14
+
15
+ export interface ComponentSource {
16
+ /** Absolute path to the file. */
17
+ path: string;
18
+ /** Which layer this slot resolved against. */
19
+ layer: Layer;
20
+ }
21
+
22
+ export interface ResolvedComponent {
23
+ /** Component kind — determines the output directory. */
24
+ type: ComponentType;
25
+ /** Path segments under `src/`, e.g. `["components", "button"]`. */
26
+ segments: string[];
27
+ /** The anchor — every component has a liquid file. */
28
+ liquid: ComponentSource;
29
+ /** Optional sibling slots; each may come from a different layer. */
30
+ ts?: ComponentSource;
31
+ css?: ComponentSource;
32
+ test?: ComponentSource;
33
+ /** Section schema authored in TS; executed at build time. */
34
+ schema?: ComponentSource;
35
+ }
36
+
37
+ const TYPE_NAMES = ["sections", "blocks", "components"] as const;
38
+ export type ComponentType = (typeof TYPE_NAMES)[number];
39
+
40
+ export async function resolveLayers(
41
+ layers: Layer[],
42
+ ): Promise<ResolvedComponent[]> {
43
+ 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("/")}`;
50
+ if (!allKeys.has(key)) {
51
+ allKeys.set(key, { type, segments });
52
+ }
53
+ });
54
+
55
+ const resolved = await Promise.all(
56
+ Array.from(allKeys.values()).map(
57
+ async ({ type, segments }): Promise<ResolvedComponent | undefined> => {
58
+ 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"),
64
+ ]);
65
+ if (!liquid) return undefined;
66
+ const component: ResolvedComponent = { type, segments, liquid };
67
+ if (ts) component.ts = ts;
68
+ if (css) component.css = css;
69
+ if (test) component.test = test;
70
+ if (schema) component.schema = schema;
71
+ return component;
72
+ },
73
+ ),
74
+ );
75
+
76
+ return resolved.filter(
77
+ (component): component is ResolvedComponent => component !== undefined,
78
+ );
79
+ }
80
+
81
+ async function walkLayer(
82
+ layer: Layer,
83
+ ): Promise<Array<{ type: ComponentType; segments: string[] }>> {
84
+ const sourceRoot = join(layer.path, "src");
85
+ if (!(await exists(sourceRoot))) return [];
86
+ const perType = await Promise.all(
87
+ TYPE_NAMES.map(async (type) => {
88
+ const typeDir = join(sourceRoot, type);
89
+ if (!(await exists(typeDir))) return [];
90
+ return walkType(typeDir, type, [type]);
91
+ }),
92
+ );
93
+ return perType.flat();
94
+ }
95
+
96
+ async function walkType(
97
+ directory: string,
98
+ type: ComponentType,
99
+ segments: string[],
100
+ ): Promise<Array<{ type: ComponentType; segments: string[] }>> {
101
+ const entries = await readdir(directory, { withFileTypes: true });
102
+ const directories = entries.filter((entry) => entry.isDirectory());
103
+
104
+ const nested = await Promise.all(
105
+ directories.map(async (entry) => {
106
+ const subdirectory = join(directory, entry.name);
107
+
108
+ // Nested type marker (e.g. `components/` under a section) shifts type
109
+ // and namespace.
110
+ if ((TYPE_NAMES as readonly string[]).includes(entry.name)) {
111
+ return walkType(subdirectory, entry.name as ComponentType, [
112
+ ...segments,
113
+ entry.name,
114
+ ]);
115
+ }
116
+
117
+ const componentSegments = [...segments, entry.name];
118
+ const liquidPath = join(subdirectory, `${entry.name}.liquid`);
119
+ const here = (await exists(liquidPath))
120
+ ? [{ type, segments: componentSegments }]
121
+ : [];
122
+
123
+ const deeper = await walkType(subdirectory, type, componentSegments);
124
+ return [...here, ...deeper];
125
+ }),
126
+ );
127
+
128
+ return nested.flat();
129
+ }
130
+
131
+ async function firstExisting(
132
+ layers: Layer[],
133
+ segments: string[],
134
+ extension: string,
135
+ ): Promise<ComponentSource | undefined> {
136
+ const last = segments[segments.length - 1];
137
+ const relativePath = join("src", ...segments, `${last}${extension}`);
138
+ const candidates = await Promise.all(
139
+ layers.map(async (layer) => ({
140
+ layer,
141
+ path: join(layer.path, relativePath),
142
+ exists: await exists(join(layer.path, relativePath)),
143
+ })),
144
+ );
145
+ const winner = candidates.find((candidate) => candidate.exists);
146
+ return winner ? { path: winner.path, layer: winner.layer } : undefined;
147
+ }
148
+
149
+ async function exists(path: string): Promise<boolean> {
150
+ try {
151
+ await stat(path);
152
+ return true;
153
+ } catch {
154
+ return false;
155
+ }
156
+ }
package/lib/schema.ts ADDED
@@ -0,0 +1,37 @@
1
+ // Public surface for authoring Shopify schemas in TypeScript. Consumers import
2
+ // the matching `defineSchema*` helper for the component kind they're authoring
3
+ // and place the result in a sibling `<name>.schema.ts` file. The build engine
4
+ // executes the file and injects the result as a `{% schema %}` block.
5
+ //
6
+ // Types are codegen'd from Shopify's authoritative JSON Schemas — see
7
+ // scripts/codegen-schema.ts.
8
+
9
+ export type { ThemeBlockSchema } from "./schema-block.generated.ts";
10
+ export type { SectionSchema } from "./schema-section.generated.ts";
11
+
12
+ import type { ThemeBlockSchema } from "./schema-block.generated.ts";
13
+ import type { SectionSchema } from "./schema-section.generated.ts";
14
+
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.
19
+ *
20
+ * Use in `<name>.schema.ts` files under `src/sections/`.
21
+ */
22
+ export function defineSchemaSection<const T extends SectionSchema>(
23
+ schema: T,
24
+ ): T {
25
+ return schema;
26
+ }
27
+
28
+ /**
29
+ * Identity helper for authoring a Shopify theme-block schema.
30
+ *
31
+ * Use in `<name>.schema.ts` files under `src/blocks/`.
32
+ */
33
+ export function defineSchemaBlock<const T extends ThemeBlockSchema>(
34
+ schema: T,
35
+ ): T {
36
+ return schema;
37
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@augeo/smelt",
3
+ "version": "1.2.2",
4
+ "description": "Library + CLI for building Shopify themes from a colocated component tree",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "smelt": "./dist/cli.mjs"
9
+ },
10
+ "exports": {
11
+ "./schema": {
12
+ "types": "./dist/schema.d.mts",
13
+ "import": "./dist/schema.mjs"
14
+ }
15
+ },
16
+ "engines": {
17
+ "node": "24.11.0",
18
+ "npm": "11.6.2"
19
+ },
20
+ "volta": {
21
+ "node": "24.11.0",
22
+ "npm": "11.6.2"
23
+ },
24
+ "scripts": {
25
+ "schema:codegen": "tsx scripts/codegen-schema.ts",
26
+ "docs:update": "git submodule update --remote vendor/theme-liquid-docs",
27
+ "prebuild": "npm run schema:codegen",
28
+ "build": "tsdown",
29
+ "build:watch": "tsdown --watch",
30
+ "lint": "biome check && prettier --check --cache .",
31
+ "lint:fix": "biome check --write && prettier --write --cache .",
32
+ "typecheck": "npm run schema:codegen && tsc --noEmit && tsc --noEmit -p src",
33
+ "verify": "npm run lint:fix && npm run typecheck",
34
+ "pretest": "npm run build",
35
+ "test": "vitest run",
36
+ "test:all": "npm test && npm test --prefix example",
37
+ "prepare": "npm run build",
38
+ "prepublishOnly": "npm run build"
39
+ },
40
+ "devDependencies": {
41
+ "@augeo/assay": "^1.3.0",
42
+ "@biomejs/biome": "^2.4.16",
43
+ "@shopify/cli": "^3",
44
+ "@shopify/prettier-plugin-liquid": "^1",
45
+ "@types/node": "^25.9.1",
46
+ "@vitest/browser-playwright": "^4.1.7",
47
+ "json-schema-to-typescript": "^15.0.4",
48
+ "prettier": "^3",
49
+ "tsdown": "^0.22.1",
50
+ "tsx": "^4.22.3",
51
+ "typescript": "^6.0.3",
52
+ "vitest": "^4.1.7"
53
+ },
54
+ "dependencies": {
55
+ "chokidar": "^5.0.0",
56
+ "citty": "^0.2.2",
57
+ "esbuild": "^0.28.0"
58
+ }
59
+ }
@@ -0,0 +1,66 @@
1
+ // Generates lib/schema-*.generated.ts from Shopify's authoritative JSON Schemas
2
+ // vendored at vendor/theme-liquid-docs/schemas/theme/. One file per schema kind
3
+ // — each is self-contained (shared supporting types like `Setting` are
4
+ // re-emitted per file rather than deduped). Run via `npm run schema:codegen`
5
+ // (also fires from the `prebuild` hook).
6
+
7
+ import { readFile, writeFile } from "node:fs/promises";
8
+ import { dirname, join, resolve } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { compile } from "json-schema-to-typescript";
11
+
12
+ const here = dirname(fileURLToPath(import.meta.url));
13
+ const repoRoot = resolve(here, "..");
14
+ const vendorRoot = join(repoRoot, "vendor/theme-liquid-docs/schemas/theme");
15
+ const outputDir = join(repoRoot, "lib");
16
+
17
+ const entries = [
18
+ {
19
+ source: "section.json",
20
+ output: "schema-section.generated.ts",
21
+ typeName: "SectionSchema",
22
+ },
23
+ {
24
+ source: "theme_block.json",
25
+ output: "schema-block.generated.ts",
26
+ typeName: "ThemeBlockSchema",
27
+ },
28
+ ] as const;
29
+
30
+ main().catch((error) => {
31
+ console.error(error);
32
+ process.exit(1);
33
+ });
34
+
35
+ async function main(): Promise<void> {
36
+ await Promise.all(entries.map(generateOne));
37
+ console.log(`Wrote ${entries.length} generated file(s) to ${outputDir}.`);
38
+ }
39
+
40
+ async function generateOne(entry: (typeof entries)[number]): Promise<void> {
41
+ const schemaJson = JSON.parse(
42
+ await readFile(join(vendorRoot, entry.source), "utf-8"),
43
+ );
44
+ // json-schema-to-typescript prefers `title` over the passed name. Drop it
45
+ // so our `typeName` wins (e.g. `SectionSchema` rather than
46
+ // `ShopifyLiquidThemeSectionSchema`).
47
+ delete schemaJson.title;
48
+
49
+ const compiled = await compile(schemaJson, entry.typeName, {
50
+ cwd: vendorRoot,
51
+ bannerComment: "",
52
+ additionalProperties: false,
53
+ declareExternallyReferenced: true,
54
+ strictIndexSignatures: true,
55
+ });
56
+
57
+ const header = [
58
+ "// Generated by scripts/codegen-schema.ts — DO NOT EDIT.",
59
+ `// Source: vendor/theme-liquid-docs/schemas/theme/${entry.source}`,
60
+ "// Update by running `npm run docs:update && npm run schema:codegen`.",
61
+ "",
62
+ "",
63
+ ].join("\n");
64
+
65
+ await writeFile(join(outputDir, entry.output), header + compiled);
66
+ }
@@ -0,0 +1,13 @@
1
+ .tr-button {
2
+ padding: 0.5rem 1rem;
3
+ font: inherit;
4
+ color: #fff;
5
+ cursor: pointer;
6
+ background: #111;
7
+ border: 0;
8
+ border-radius: 4px;
9
+ }
10
+
11
+ .tr-button:hover {
12
+ background: #333;
13
+ }
@@ -0,0 +1,5 @@
1
+ {%- liquid
2
+ assign label = label | default: 'Click me'
3
+ -%}
4
+
5
+ <button class="tr-button" type="button">{{ label }}</button>
@@ -0,0 +1,5 @@
1
+ document.querySelectorAll<HTMLButtonElement>(".tr-button").forEach((button) => {
2
+ button.addEventListener("click", () => {
3
+ console.log("tr-button clicked");
4
+ });
5
+ });
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../tsconfig.json",
3
+ "compilerOptions": {
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "paths": {
6
+ "@/*": ["./*"]
7
+ }
8
+ },
9
+ "include": ["**/*.ts"]
10
+ }