@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,314 @@
1
+ // Compiles a merged source tree into Shopify-flat output directories. See
2
+ // docs/build-spec.md for the full spec.
3
+
4
+ import {
5
+ mkdir,
6
+ readdir,
7
+ readFile,
8
+ rm,
9
+ stat,
10
+ writeFile,
11
+ } from "node:fs/promises";
12
+ import { join, relative } from "node:path";
13
+ import { type BuildOptions, build as esbuild } from "esbuild";
14
+ import {
15
+ type ComponentSource,
16
+ type ComponentType,
17
+ type Layer,
18
+ type ResolvedComponent,
19
+ resolveLayers,
20
+ } from "../resolver.ts";
21
+
22
+ export interface BuildContext {
23
+ /** Ordered source layers — first wins on per-file shadowing. */
24
+ layers: Layer[];
25
+ }
26
+
27
+ const BUILT_PREFIX = "built--";
28
+ // Matches anything the build emits: regular `built--*.liquid` and private
29
+ // `_built--*.liquid` theme blocks (see docs/build-spec.md "Private blocks").
30
+ const BUILT_FILE_PATTERN = /^_?built--/;
31
+
32
+ const TYPE_TO_OUTPUT_DIRECTORY = {
33
+ sections: "sections",
34
+ blocks: "blocks",
35
+ components: "snippets",
36
+ } as const satisfies Record<ComponentType, string>;
37
+
38
+ export async function build(context: BuildContext): Promise<number> {
39
+ const outputRoot = context.layers[0]?.path;
40
+ if (!outputRoot) {
41
+ throw new Error("build() requires at least one layer.");
42
+ }
43
+ const components = await resolveLayers(context.layers);
44
+ await cleanBuiltOutputs(outputRoot);
45
+
46
+ const lookup = new Map(
47
+ components.map((component) => [component.segments.join("/"), component]),
48
+ );
49
+ await Promise.all(
50
+ components.map((component) =>
51
+ buildComponent(component, lookup, outputRoot),
52
+ ),
53
+ );
54
+
55
+ return components.length;
56
+ }
57
+
58
+ async function cleanBuiltOutputs(outputRoot: string): Promise<void> {
59
+ await Promise.all(
60
+ Object.values(TYPE_TO_OUTPUT_DIRECTORY).map(async (directory) => {
61
+ const directoryPath = join(outputRoot, directory);
62
+ if (!(await exists(directoryPath))) return;
63
+ const entries = await readdir(directoryPath);
64
+ await Promise.all(
65
+ entries
66
+ .filter((entry) => BUILT_FILE_PATTERN.test(entry))
67
+ .map((entry) => rm(join(directoryPath, entry))),
68
+ );
69
+ }),
70
+ );
71
+ }
72
+
73
+ async function buildComponent(
74
+ component: ResolvedComponent,
75
+ lookup: Map<string, ResolvedComponent>,
76
+ outputRoot: string,
77
+ ): Promise<void> {
78
+ const source = await readFile(component.liquid.path, "utf-8");
79
+ assertNoInlineSchema(source, component);
80
+ const body = rewriteRenders(source, component, lookup);
81
+
82
+ const sections = [banner(component), body.trimEnd()];
83
+
84
+ if (component.schema) {
85
+ const schema = await loadSchema(component.schema);
86
+ const merged = mergePrivateBlocks(schema, component, lookup);
87
+ sections.push(renderSchemaBlock(merged));
88
+ }
89
+ if (component.css) {
90
+ const css = await readFile(component.css.path, "utf-8");
91
+ sections.push(wrapEmbedded("stylesheet", css));
92
+ }
93
+ if (component.ts) {
94
+ sections.push(wrapEmbedded("javascript", await bundleJs(component.ts)));
95
+ }
96
+
97
+ const liquid = `${sections.join("\n\n")}\n`;
98
+
99
+ const outputDirectory = join(
100
+ outputRoot,
101
+ TYPE_TO_OUTPUT_DIRECTORY[component.type],
102
+ );
103
+ const outputName = `${builtNameOf(component)}.liquid`;
104
+ const outputPath = join(outputDirectory, outputName);
105
+
106
+ await mkdir(outputDirectory, { recursive: true });
107
+ await writeFile(outputPath, liquid);
108
+ }
109
+
110
+ function assertNoInlineSchema(
111
+ source: string,
112
+ component: ResolvedComponent,
113
+ ): void {
114
+ if (!/\{%-?\s*schema\s*-?%\}/.test(source)) return;
115
+ const last = component.segments[component.segments.length - 1];
116
+ throw new Error(
117
+ `Inline {% schema %} block in ${sourcePathForComponent(component)}. ` +
118
+ `Schemas must live in a sibling ${last}.schema.ts file — import ` +
119
+ `{ defineSchemaSection } (or defineSchemaBlock) from "@augeo/smelt/schema".`,
120
+ );
121
+ }
122
+
123
+ async function loadSchema(schemaSource: ComponentSource): Promise<unknown> {
124
+ // esbuild auto-discovers tsconfig.json by walking up from the entry file,
125
+ // so `paths` (including the `@/*` alias) come from whichever tsconfig the
126
+ // schema's own layer puts closest. This matches what `tsc` sees.
127
+ const code = await bundle(schemaSource.path, {
128
+ format: "esm",
129
+ platform: "node",
130
+ target: "node24",
131
+ });
132
+ const loaded = await import(
133
+ `data:text/javascript,${encodeURIComponent(code)}`
134
+ );
135
+ if (loaded.schema === undefined) {
136
+ const available = Object.keys(loaded).join(", ") || "no exports";
137
+ throw new Error(
138
+ `${schemaSource.path} must export a named 'schema' (found: ${available}).`,
139
+ );
140
+ }
141
+ return loaded.schema;
142
+ }
143
+
144
+ function renderSchemaBlock(schema: unknown): string {
145
+ return `{% schema %}\n${JSON.stringify(schema, null, 2)}\n{% endschema %}`;
146
+ }
147
+
148
+ /**
149
+ * A block is "private" when its segments were reached through a nested
150
+ * `blocks/` type marker — i.e. there is a `blocks` segment at any position
151
+ * after the root. That covers both a section/component owning private blocks
152
+ * (`sections/hero/blocks/headline`) and a public block owning its own private
153
+ * blocks (`blocks/promo/blocks/item`). Shopify treats files prefixed `_` in
154
+ * `blocks/` as hidden from the merchant picker; we map our nested authoring
155
+ * convention onto that filename convention.
156
+ */
157
+ function isPrivateBlock(component: ResolvedComponent): boolean {
158
+ if (component.type !== "blocks") return false;
159
+ return component.segments.slice(1).includes("blocks");
160
+ }
161
+
162
+ /** Filename minus `.liquid` — also the Shopify-facing `type` for blocks. */
163
+ function builtNameOf(component: ResolvedComponent): string {
164
+ const prefix = isPrivateBlock(component) ? "_" : "";
165
+ return `${prefix}${BUILT_PREFIX}${component.segments.join("--")}`;
166
+ }
167
+
168
+ /**
169
+ * Auto-merge immediate private children (blocks under `<parent>/blocks/`) into
170
+ * the parent's `blocks: [...]` schema array. Discovered children are sorted
171
+ * by directory name and prepended; any explicitly-listed entries the author
172
+ * wrote are appended after.
173
+ */
174
+ function mergePrivateBlocks(
175
+ schema: unknown,
176
+ component: ResolvedComponent,
177
+ lookup: Map<string, ResolvedComponent>,
178
+ ): unknown {
179
+ if (!isPlainObject(schema)) return schema;
180
+ const children = findPrivateChildren(component, lookup);
181
+ if (children.length === 0) return schema;
182
+ const discovered = children.map((child) => ({ type: builtNameOf(child) }));
183
+ const explicit = Array.isArray(schema.blocks) ? schema.blocks : [];
184
+ return { ...schema, blocks: [...discovered, ...explicit] };
185
+ }
186
+
187
+ function findPrivateChildren(
188
+ component: ResolvedComponent,
189
+ lookup: Map<string, ResolvedComponent>,
190
+ ): ResolvedComponent[] {
191
+ const parent = component.segments;
192
+ const candidates = Array.from(lookup.values()).filter((other) => {
193
+ if (other.type !== "blocks") return false;
194
+ // Immediate child only: segments == [...parent, "blocks", <name>]
195
+ if (other.segments.length !== parent.length + 2) return false;
196
+ if (other.segments[parent.length] !== "blocks") return false;
197
+ return parent.every((segment, index) => other.segments[index] === segment);
198
+ });
199
+ return candidates.sort((a, b) =>
200
+ (a.segments[a.segments.length - 1] ?? "").localeCompare(
201
+ b.segments[b.segments.length - 1] ?? "",
202
+ ),
203
+ );
204
+ }
205
+
206
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
207
+ return typeof value === "object" && value !== null && !Array.isArray(value);
208
+ }
209
+
210
+ function banner(component: ResolvedComponent): string {
211
+ return `{%- comment -%}
212
+ GENERATED FROM ${sourcePathForComponent(component)} — do not edit this file directly.
213
+ Edit the source and run \`npm run build\`.
214
+ {%- endcomment -%}`;
215
+ }
216
+
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}`;
223
+ }
224
+
225
+ function wrapEmbedded(tag: "stylesheet" | "javascript", body: string): string {
226
+ return `{% ${tag} %}\n${indent(body.trimEnd())}\n{% end${tag} %}`;
227
+ }
228
+
229
+ /** Prepends a tab to each non-empty line. */
230
+ function indent(text: string): string {
231
+ return text.replace(/^(?=.)/gm, "\t");
232
+ }
233
+
234
+ function rewriteRenders(
235
+ source: string,
236
+ component: ResolvedComponent,
237
+ lookup: Map<string, ResolvedComponent>,
238
+ ): string {
239
+ return source.replace(
240
+ /(\{%-?\s*(?:render|include)\s+['"])((?:@|\.)\/[^'"]+)(['"])/g,
241
+ (_match, prefix, importPath, suffix) => {
242
+ const targetSegments = resolveImport(importPath, component);
243
+ const lookupKey = targetSegments.join("/");
244
+ const target = lookup.get(lookupKey);
245
+ if (!target) {
246
+ throw new Error(
247
+ `Cannot resolve render '${importPath}' (looked up '${lookupKey}') from ${component.liquid.path}`,
248
+ );
249
+ }
250
+ return `${prefix}${builtNameOf(target)}${suffix}`;
251
+ },
252
+ );
253
+ }
254
+
255
+ function resolveImport(
256
+ importPath: string,
257
+ component: ResolvedComponent,
258
+ ): string[] {
259
+ if (importPath.startsWith("@/")) {
260
+ return importPath.slice(2).split("/").filter(Boolean);
261
+ }
262
+ if (importPath.startsWith("./")) {
263
+ // `../` is intentionally not supported — see docs/build-spec.md.
264
+ if (importPath.includes("../")) {
265
+ throw new Error(
266
+ `'../' is not supported in render path: '${importPath}' (in ${component.liquid.path})`,
267
+ );
268
+ }
269
+ const remaining = importPath.slice(2).split("/").filter(Boolean);
270
+ return [...component.segments, ...remaining];
271
+ }
272
+ throw new Error(`Unexpected import path: '${importPath}'`);
273
+ }
274
+
275
+ async function bundleJs(tsSource: ComponentSource): Promise<string> {
276
+ // `absWorkingDir` pins esbuild's source-path annotation (`// src/...`) to
277
+ // the file's own layer, so baseline files don't get a leading `../` just
278
+ // because the CLI happens to be invoked from the consumer's cwd.
279
+ const code = await bundle(tsSource.path, {
280
+ format: "iife",
281
+ platform: "browser",
282
+ target: "es2022",
283
+ absWorkingDir: tsSource.layer.path,
284
+ });
285
+ // esbuild always emits 2-space indents; project style is tabs.
286
+ return code.replace(/^( {2})+/gm, (match) => "\t".repeat(match.length / 2));
287
+ }
288
+
289
+ async function bundle(
290
+ entryPoint: string,
291
+ options: Partial<BuildOptions>,
292
+ ): Promise<string> {
293
+ const result = await esbuild({
294
+ entryPoints: [entryPoint],
295
+ bundle: true,
296
+ write: false,
297
+ logLevel: "warning",
298
+ ...options,
299
+ });
300
+ const [first] = result.outputFiles ?? [];
301
+ if (!first) {
302
+ throw new Error(`esbuild produced no output for ${entryPoint}`);
303
+ }
304
+ return first.text;
305
+ }
306
+
307
+ async function exists(path: string): Promise<boolean> {
308
+ try {
309
+ await stat(path);
310
+ return true;
311
+ } catch {
312
+ return false;
313
+ }
314
+ }
@@ -0,0 +1,27 @@
1
+ import { dirname } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { defineCommand } from "citty";
4
+ import { build as runBuild } from "./build.ts";
5
+
6
+ // After tsdown bundles to `dist/cli.mjs`, `import.meta.url` points at that
7
+ // bundle. Walk up two levels to the package root, where the baseline `src/`
8
+ // lives. For the consumer (the running process's cwd), the consumer's `src/`
9
+ // lives directly under it.
10
+ const cliPath = fileURLToPath(import.meta.url);
11
+ const smeltRoot = dirname(dirname(cliPath));
12
+
13
+ export const build = defineCommand({
14
+ meta: {
15
+ name: "build",
16
+ description: "Compile src/ into Shopify-flat output directories.",
17
+ },
18
+ async run() {
19
+ const count = await runBuild({
20
+ layers: [
21
+ { name: "consumer", path: process.cwd() },
22
+ { name: "@augeo/smelt", path: smeltRoot },
23
+ ],
24
+ });
25
+ console.log(`Built ${count} component(s).`);
26
+ },
27
+ });
@@ -0,0 +1 @@
1
+ export { build } from "./command.ts";
package/lib/cli.ts ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ import { defineCommand, runMain } from "citty";
3
+ import { build } from "./build/index.ts";
4
+ import { dev } from "./dev/index.ts";
5
+
6
+ const main = defineCommand({
7
+ meta: {
8
+ name: "smelt",
9
+ description: "Smelt build CLI.",
10
+ },
11
+ subCommands: {
12
+ build,
13
+ dev,
14
+ },
15
+ });
16
+
17
+ runMain(main);
@@ -0,0 +1,25 @@
1
+ import { dirname } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { defineCommand } from "citty";
4
+ import { runWatch } from "./watch.ts";
5
+
6
+ // Same self-locating dance as `lib/build/command.ts` — after tsdown bundles to
7
+ // `dist/cli.mjs`, `import.meta.url` points at the bundle, so walk up two
8
+ // levels to reach the package root that hosts the baseline `src/` layer.
9
+ const cliPath = fileURLToPath(import.meta.url);
10
+ const smeltRoot = dirname(dirname(cliPath));
11
+
12
+ export const dev = defineCommand({
13
+ meta: {
14
+ name: "dev",
15
+ description: "Watch src/ and rebuild on change.",
16
+ },
17
+ async run() {
18
+ await runWatch({
19
+ layers: [
20
+ { name: "consumer", path: process.cwd() },
21
+ { name: "@augeo/smelt", path: smeltRoot },
22
+ ],
23
+ });
24
+ },
25
+ });
@@ -0,0 +1 @@
1
+ export { dev } from "./command.ts";
@@ -0,0 +1,52 @@
1
+ import { join } from "node:path";
2
+ import { watch } from "chokidar";
3
+ import { type BuildContext, build } from "../build/build.ts";
4
+
5
+ // Editors often write files in bursts (saves of multiple files, atomic
6
+ // rename-into-place writes, etc.). Coalesce those into one rebuild.
7
+ const DEBOUNCE_MS = 100;
8
+
9
+ export async function runWatch(context: BuildContext): Promise<void> {
10
+ await safeBuild(context);
11
+
12
+ const sources = context.layers.map((layer) => join(layer.path, "src"));
13
+ const watcher = watch(sources, {
14
+ ignoreInitial: true,
15
+ ignored: /node_modules/,
16
+ });
17
+
18
+ let pending: NodeJS.Timeout | undefined;
19
+ function scheduleRebuild(): void {
20
+ if (pending) clearTimeout(pending);
21
+ pending = setTimeout(() => {
22
+ pending = undefined;
23
+ void safeBuild(context);
24
+ }, DEBOUNCE_MS);
25
+ }
26
+
27
+ watcher.on("add", scheduleRebuild);
28
+ watcher.on("change", scheduleRebuild);
29
+ watcher.on("unlink", scheduleRebuild);
30
+
31
+ process.on("SIGINT", async () => {
32
+ await watcher.close();
33
+ process.exit(0);
34
+ });
35
+
36
+ console.log(
37
+ `smelt dev — watching ${sources.length} layer(s). Ctrl-C to stop.`,
38
+ );
39
+ // Watcher holds the event loop open; this promise never resolves.
40
+ return new Promise(() => {});
41
+ }
42
+
43
+ async function safeBuild(context: BuildContext): Promise<void> {
44
+ const start = Date.now();
45
+ try {
46
+ const count = await build(context);
47
+ console.log(`✓ Built ${count} component(s) in ${Date.now() - start}ms`);
48
+ } catch (error) {
49
+ const message = error instanceof Error ? error.message : String(error);
50
+ console.error(`✗ Build failed: ${message}`);
51
+ }
52
+ }