create-magic-storefront 0.3.1 → 0.4.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.
Files changed (42) hide show
  1. package/README.md +18 -1
  2. package/dist/index.js +523 -9
  3. package/package.json +7 -1
  4. package/template/.claude/skills/theme-section/SKILL.md +157 -0
  5. package/template/.env.example +7 -1
  6. package/template/.screenshots/home-1280.png +0 -0
  7. package/template/.screenshots/home-390.png +0 -0
  8. package/template/.screenshots/home-filled-1280.png +0 -0
  9. package/template/.screenshots/home-filled-390.png +0 -0
  10. package/template/.screenshots/page-1280.png +0 -0
  11. package/template/AGENTS.md +54 -17
  12. package/template/PAGES.md +39 -22
  13. package/template/README.md +6 -0
  14. package/template/_gitignore +2 -0
  15. package/template/app/api/magicstore/preview/route.ts +56 -0
  16. package/template/app/globals.css +108 -0
  17. package/template/app/layout.tsx +16 -6
  18. package/template/app/page.tsx +18 -9
  19. package/template/app/pages/[handle]/page.tsx +26 -11
  20. package/template/components/sections/collections.tsx +2 -1
  21. package/template/components/sections/product-shelves.tsx +2 -1
  22. package/template/lib/api.ts +40 -10
  23. package/template/lib/i18n.ts +6 -0
  24. package/template/lib/preview.ts +21 -0
  25. package/template/llms.txt +140 -1
  26. package/template/package.json +6 -3
  27. package/template/scripts/theme.mjs +53 -0
  28. package/template/theme/index.ts +53 -0
  29. package/template/theme/sections/collection-list.tsx +64 -0
  30. package/template/theme/sections/featured-collection.tsx +56 -0
  31. package/template/theme/sections/hero.tsx +95 -0
  32. package/template/theme/sections/image-with-text.tsx +71 -0
  33. package/template/theme/sections/page-content.tsx +45 -0
  34. package/template/theme/sections/platform-home.tsx +37 -0
  35. package/template/theme/sections/product-shelf.tsx +96 -0
  36. package/template/theme/sections/rich-text.tsx +43 -0
  37. package/template/theme/sections/testimonials.tsx +84 -0
  38. package/template/theme/settings.ts +19 -0
  39. package/template/theme/templates/index.json +9 -0
  40. package/template/theme/templates/page.json +6 -0
  41. package/template/theme/text.ts +21 -0
  42. package/template/tsconfig.json +1 -1
package/README.md CHANGED
@@ -17,7 +17,7 @@ npx create-magic-storefront check [directory]
17
17
  Fails (exit 1, one line per finding) when the storefront uses the SDK outside its public API:
18
18
 
19
19
  - `DEEP_IMPORT` — an `@magicstoreai/*` import other than `@magicstoreai/storefront-client`,
20
- `@magicstoreai/hydrogen`, `@magicstoreai/hydrogen/core`, `/server` or `/seo` (a `src/` or `dist/`
20
+ `@magicstoreai/hydrogen`, `@magicstoreai/hydrogen/core`, `/server`, `/seo` or `/theme` (a `src/` or `dist/`
21
21
  path, an unexported subpath, the SDK's sources in this repository);
22
22
  - `RAW_API_CALL` — a string that spells out an endpoint (`/api/v2/storefront/…`) instead of calling
23
23
  it through the client. The bare base URL handed to `createStorefrontClient` is fine.
@@ -30,3 +30,20 @@ Fails (exit 1, one line per finding) when the storefront uses the SDK outside it
30
30
 
31
31
  `node_modules`, build output and `.d.ts` files are skipped. In this repository `pnpm guard` runs it on
32
32
  `examples/starter` (CI too).
33
+
34
+ ## Themes
35
+
36
+ ```bash
37
+ npx create-magic-storefront theme check [directory] # sections, templates, defaults, copy in components
38
+ npx create-magic-storefront theme build [directory] # the manifest → .magicstore/theme.json
39
+ npx create-magic-storefront theme push [directory] # send it to the shop (UNPUBLISHED; --publish to go live)
40
+ npx create-magic-storefront theme dev [directory] # push to DEVELOPMENT on every change under theme/
41
+ ```
42
+
43
+ The theme is `theme/index.ts` (`defineTheme`) of the storefront and the files of `theme/sections/`, loaded
44
+ with the storefront's own SDK. `check` fails on a section file that is not registered
45
+ (`SECTION_NOT_REGISTERED`), an invalid template or a text setting without a default in every locale
46
+ (`--locales`, default `en,ru,uz`); copy written into a component is a warning (`HARDCODED_COPY`), an error
47
+ with `--strict`. `push` and `dev` need `MAGICSTORE_THEME_TOKEN` — the theme deploy token (`mtt_…`), never a
48
+ storefront token — and `MAGICSTORE_SHOP_DOMAIN` or `MAGICSTORE_API_URL`, from the environment or
49
+ `.env.local`; only the token's prefix is printed. Guide: [docs/themes.md](../../docs/themes.md).
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { readFileSync as readFileSync3 } from "fs";
5
- import { dirname, relative as relative2, resolve as resolve3 } from "path";
4
+ import { readFileSync as readFileSync5 } from "fs";
5
+ import { dirname, relative as relative3, resolve as resolve6 } from "path";
6
6
  import { createInterface } from "readline/promises";
7
7
  import { fileURLToPath } from "url";
8
8
  import { parseArgs } from "util";
@@ -15,7 +15,8 @@ var PUBLIC_ENTRY_POINTS = [
15
15
  "@magicstoreai/hydrogen",
16
16
  "@magicstoreai/hydrogen/core",
17
17
  "@magicstoreai/hydrogen/server",
18
- "@magicstoreai/hydrogen/seo"
18
+ "@magicstoreai/hydrogen/seo",
19
+ "@magicstoreai/hydrogen/theme"
19
20
  ];
20
21
  var SOURCE_EXTENSIONS = /\.(?:[cm]?[jt]sx?)$/;
21
22
  var SKIPPED_DIRECTORIES = /* @__PURE__ */ new Set([
@@ -175,21 +176,523 @@ function envFile(example, options) {
175
176
  }).join("\n");
176
177
  }
177
178
 
179
+ // src/theme-commands.ts
180
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync4, watch, writeFileSync as writeFileSync3 } from "fs";
181
+ import { join as join5, resolve as resolve5 } from "path";
182
+
183
+ // src/theme-check.ts
184
+ import { readFileSync as readFileSync3 } from "fs";
185
+ import { createRequire as createRequire2 } from "module";
186
+ import { join as join4, resolve as resolve4 } from "path";
187
+
188
+ // src/theme-load.ts
189
+ import { existsSync as existsSync2, mkdirSync, readdirSync as readdirSync3, rmSync, writeFileSync as writeFileSync2 } from "fs";
190
+ import { join as join3, relative as relative2, resolve as resolve3 } from "path";
191
+ import { createRequire } from "module";
192
+ import { build } from "esbuild";
193
+ var OUTPUT_DIRECTORY = ".magicstore";
194
+ var THEME_ENTRY = "theme/index.ts";
195
+ var SECTIONS_DIRECTORY = "theme/sections";
196
+ var ThemeLoadError = class extends Error {
197
+ name = "ThemeLoadError";
198
+ };
199
+ var STUBBED_MODULES = ["server-only", "client-only"];
200
+ var stubs = {
201
+ name: "magicstore-stubs",
202
+ setup(builder) {
203
+ const filter = new RegExp(`^(?:${STUBBED_MODULES.join("|")})$`);
204
+ builder.onResolve({ filter }, (args) => ({ path: args.path, namespace: "magicstore-stub" }));
205
+ builder.onLoad({ filter: /.*/, namespace: "magicstore-stub" }, () => ({ contents: "" }));
206
+ }
207
+ };
208
+ function sectionSourceFiles(directory) {
209
+ const sections = resolve3(directory, SECTIONS_DIRECTORY);
210
+ if (!existsSync2(sections)) {
211
+ return [];
212
+ }
213
+ return readdirSync3(sections, { withFileTypes: true }).filter((entry) => entry.isFile() && /\.[cm]?[jt]sx?$/.test(entry.name)).filter((entry) => !/\.(?:test|spec)\.[cm]?[jt]sx?$/.test(entry.name)).map((entry) => join3(sections, entry.name)).sort();
214
+ }
215
+ async function loadProject(directory) {
216
+ const root = resolve3(directory);
217
+ const entry = resolve3(root, THEME_ENTRY);
218
+ if (!existsSync2(entry)) {
219
+ throw new ThemeLoadError(
220
+ `${THEME_ENTRY} not found in ${root}: a theme starts with defineTheme there.`
221
+ );
222
+ }
223
+ const files = sectionSourceFiles(root);
224
+ const lines = [
225
+ `import * as entry from ${JSON.stringify(entry)};`,
226
+ ...files.map((file, index) => `import * as s${index} from ${JSON.stringify(file)};`),
227
+ `export const theme = entry.default ?? entry.theme;`,
228
+ `export const sections = [${files.map((_, index) => `s${index}`).join(", ")}];`,
229
+ `export * as sdk from '@magicstoreai/hydrogen/theme';`,
230
+ `export * as client from '@magicstoreai/storefront-client';`
231
+ ];
232
+ const staging = resolve3(root, OUTPUT_DIRECTORY);
233
+ mkdirSync(staging, { recursive: true });
234
+ const bundlePath = join3(staging, `theme-loader-${process.pid}-${Date.now()}.cjs`);
235
+ try {
236
+ const result = await build({
237
+ stdin: {
238
+ contents: lines.join("\n"),
239
+ resolveDir: root,
240
+ loader: "ts",
241
+ sourcefile: "theme-loader.ts"
242
+ },
243
+ absWorkingDir: root,
244
+ bundle: true,
245
+ write: false,
246
+ platform: "node",
247
+ // CommonJS: Node's require resolves packages without an exports map (`next/link`), and loads
248
+ // ESM-only ones too (require(esm), Node 22.12+).
249
+ format: "cjs",
250
+ target: "node22",
251
+ jsx: "automatic",
252
+ packages: "external",
253
+ loader: { ".css": "empty" },
254
+ plugins: [stubs],
255
+ logLevel: "silent"
256
+ });
257
+ writeFileSync2(bundlePath, result.outputFiles[0].text);
258
+ } catch (error) {
259
+ const messages = error.errors;
260
+ const detail = messages ? messages.map((m) => m.location ? `${m.location.file}:${m.location.line}: ${m.text}` : m.text).join("\n") : String(error);
261
+ throw new ThemeLoadError(`The theme does not compile:
262
+ ${detail}`);
263
+ }
264
+ let loaded;
265
+ try {
266
+ const load = createRequire(bundlePath);
267
+ loaded = load(bundlePath);
268
+ delete load.cache[bundlePath];
269
+ } catch (error) {
270
+ throw new ThemeLoadError(
271
+ `Loading the theme failed: ${error instanceof Error ? error.message : String(error)}`
272
+ );
273
+ } finally {
274
+ rmSync(bundlePath, { force: true });
275
+ }
276
+ const theme2 = loaded.theme;
277
+ if (typeof theme2 !== "object" || theme2 === null || !Array.isArray(theme2.sections)) {
278
+ throw new ThemeLoadError(
279
+ `${THEME_ENTRY} must export the theme: \`export default defineTheme({ \u2026 })\`.`
280
+ );
281
+ }
282
+ const sectionFiles = files.map((file, index) => ({
283
+ file: relative2(root, file).split("\\").join("/"),
284
+ types: Object.values(loaded.sections[index] ?? {}).filter((value) => loaded.sdk.isSectionDefinition(value)).map((section) => section.type)
285
+ }));
286
+ return { theme: theme2, sdk: loaded.sdk, client: loaded.client, sectionFiles };
287
+ }
288
+
289
+ // src/theme-check.ts
290
+ var DEFAULT_LOCALES = ["en", "ru", "uz"];
291
+ var TEXT_TYPES = /* @__PURE__ */ new Set(["TEXT", "TEXTAREA", "RICHTEXT"]);
292
+ var COPY_ATTRIBUTES = /* @__PURE__ */ new Set(["alt", "aria-label", "placeholder", "title"]);
293
+ var OPT_OUT = "theme-copy-ok";
294
+ async function checkTheme(directory, options = {}) {
295
+ const project = await loadProject(directory);
296
+ const { theme: theme2, sdk, sectionFiles } = project;
297
+ const issues = [];
298
+ for (const [name, template] of Object.entries(theme2.templates)) {
299
+ for (const issue of sdk.validateTemplate(template, theme2, { template: name })) {
300
+ issues.push({ severity: "error", ...issue, path: `templates.${name}.${issue.path}` });
301
+ }
302
+ }
303
+ const registered = new Set(theme2.sections.map((section) => section.type));
304
+ for (const { file, types } of sectionFiles) {
305
+ for (const type of types.filter((t) => !registered.has(t))) {
306
+ issues.push({
307
+ severity: "error",
308
+ path: file,
309
+ code: "SECTION_NOT_REGISTERED",
310
+ message: `the section "${type}" is defined here but not listed in defineTheme({ sections }) in theme/index.ts, so it is never pushed or rendered.`
311
+ });
312
+ }
313
+ }
314
+ issues.push(...missingDefaults(theme2, options.locales ?? DEFAULT_LOCALES));
315
+ issues.push(
316
+ ...hardcodedCopy(
317
+ directory,
318
+ sectionFiles.map((section) => section.file),
319
+ options.strict ? "error" : "warning"
320
+ )
321
+ );
322
+ return { project, manifest: sdk.buildManifest(theme2), issues };
323
+ }
324
+ function missingDefaults(theme2, locales) {
325
+ const issues = [];
326
+ const visit = (settings, path) => {
327
+ (settings ?? []).forEach((setting, index) => {
328
+ if (!TEXT_TYPES.has(setting.type)) {
329
+ return;
330
+ }
331
+ const at = `${path}.${index}.default`;
332
+ const value = setting.default;
333
+ let problem = null;
334
+ if (value === void 0) {
335
+ problem = "has no default";
336
+ } else if (typeof value === "string") {
337
+ problem = value === "" ? null : "has one default for every language";
338
+ } else if (typeof value === "object" && value !== null) {
339
+ const missing = locales.filter((locale) => !(locale in value));
340
+ problem = missing.length > 0 ? `has no default in ${missing.join(", ")}` : null;
341
+ }
342
+ if (problem !== null) {
343
+ issues.push({
344
+ severity: "warning",
345
+ path: at,
346
+ code: "MISSING_DEFAULT",
347
+ message: `the text setting "${setting.id}" ${problem}: give a default in ${locales.join(", ")}.`
348
+ });
349
+ }
350
+ });
351
+ };
352
+ visit(theme2.settings, "settings");
353
+ theme2.sections.forEach((section, index) => {
354
+ visit(section.settings, `sections.${index}.settings`);
355
+ (section.blocks ?? []).forEach(
356
+ (block, blockIndex) => visit(block.settings, `sections.${index}.blocks.${blockIndex}.settings`)
357
+ );
358
+ });
359
+ return issues;
360
+ }
361
+ function loadTypeScript(directory) {
362
+ try {
363
+ return createRequire2(join4(resolve4(directory), "package.json"))("typescript");
364
+ } catch {
365
+ return null;
366
+ }
367
+ }
368
+ function hardcodedCopy(directory, files, severity) {
369
+ if (files.length === 0) {
370
+ return [];
371
+ }
372
+ const ts = loadTypeScript(directory);
373
+ if (ts === null) {
374
+ return [
375
+ {
376
+ severity: "warning",
377
+ path: "theme/sections",
378
+ code: "HARDCODED_COPY",
379
+ message: "not checked: install typescript in the storefront to check section copy."
380
+ }
381
+ ];
382
+ }
383
+ const issues = [];
384
+ for (const file of files) {
385
+ const text = readFileSync3(resolve4(directory, file), "utf8");
386
+ const lines = text.split("\n");
387
+ const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
388
+ const report = (node, copy) => {
389
+ const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line;
390
+ if (lines[line]?.includes(OPT_OUT) || lines[line - 1]?.includes(OPT_OUT)) {
391
+ return;
392
+ }
393
+ issues.push({
394
+ severity,
395
+ path: `${file}:${line + 1}`,
396
+ code: "HARDCODED_COPY",
397
+ message: `"${copy}" is copy the merchant cannot edit: put it in a TEXT setting with a default (or mark it ${OPT_OUT}).`
398
+ });
399
+ };
400
+ const visit = (node) => {
401
+ if (ts.isJsxText(node)) {
402
+ const copy = node.text.trim().replace(/\s+/g, " ");
403
+ if (new RegExp("\\p{L}", "u").test(copy)) {
404
+ report(node, copy);
405
+ }
406
+ } else if (ts.isJsxAttribute(node) && COPY_ATTRIBUTES.has(node.name.getText(source))) {
407
+ const initializer = node.initializer;
408
+ const literal = initializer === void 0 ? void 0 : ts.isStringLiteral(initializer) ? initializer : ts.isJsxExpression(initializer) && initializer.expression !== void 0 && ts.isStringLiteralLike(initializer.expression) ? initializer.expression : void 0;
409
+ if (literal !== void 0 && new RegExp("\\p{L}", "u").test(literal.text)) {
410
+ report(node, literal.text);
411
+ }
412
+ }
413
+ ts.forEachChild(node, visit);
414
+ };
415
+ visit(source);
416
+ }
417
+ return issues;
418
+ }
419
+ function formatIssue(issue) {
420
+ const level = issue.severity === "warning" ? " (warning)" : "";
421
+ return `${issue.path}: ${issue.code}${level} ${issue.message}`;
422
+ }
423
+
424
+ // src/theme-commands.ts
425
+ var processReporter = {
426
+ out: (line) => process.stdout.write(`${line}
427
+ `),
428
+ err: (line) => process.stderr.write(`${line}
429
+ `)
430
+ };
431
+ var CommandFailed = class extends Error {
432
+ name = "CommandFailed";
433
+ };
434
+ function summary(manifest) {
435
+ const templates = Object.keys(manifest.templates).length;
436
+ return `theme ${manifest.name}@${manifest.version}: ${manifest.sections.length} sections, ${templates} templates`;
437
+ }
438
+ async function checked(directory, options, reporter) {
439
+ let result;
440
+ try {
441
+ result = await checkTheme(directory, options);
442
+ } catch (error) {
443
+ if (error instanceof ThemeLoadError) {
444
+ throw new CommandFailed(error.message);
445
+ }
446
+ throw error;
447
+ }
448
+ for (const issue of result.issues) {
449
+ reporter.err(formatIssue(issue));
450
+ }
451
+ const errors = result.issues.filter((issue) => issue.severity === "error").length;
452
+ if (errors > 0) {
453
+ throw new CommandFailed(`${errors} theme error(s).`);
454
+ }
455
+ return result;
456
+ }
457
+ async function themeCheck(directory, options = {}, reporter = processReporter) {
458
+ const result = await checked(directory, options, reporter);
459
+ reporter.out(summary(result.manifest));
460
+ return result;
461
+ }
462
+ async function themeBuild(directory, options = {}, reporter = processReporter) {
463
+ const result = await checked(directory, options, reporter);
464
+ const output = resolve5(directory, OUTPUT_DIRECTORY);
465
+ mkdirSync2(output, { recursive: true });
466
+ writeFileSync3(join5(output, "theme.json"), `${JSON.stringify(result.manifest, null, 2)}
467
+ `);
468
+ reporter.out(`${summary(result.manifest)} \u2192 ${OUTPUT_DIRECTORY}/theme.json`);
469
+ return result;
470
+ }
471
+ function storefrontEnv(directory, environment = process.env) {
472
+ const values = {};
473
+ for (const name of [".env", ".env.local"]) {
474
+ const file = resolve5(directory, name);
475
+ if (!existsSync3(file)) {
476
+ continue;
477
+ }
478
+ for (const line of readFileSync4(file, "utf8").split("\n")) {
479
+ const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line);
480
+ if (match) {
481
+ values[match[1]] = match[2].replace(/^(['"])(.*)\1$/, "$2");
482
+ }
483
+ }
484
+ }
485
+ for (const [key, value] of Object.entries(environment)) {
486
+ if (value !== void 0 && value !== "") {
487
+ values[key] = value;
488
+ }
489
+ }
490
+ return values;
491
+ }
492
+ function fileForField(field, project, manifest) {
493
+ const [head, index] = field.split(".");
494
+ if (head === "sections" && index !== void 0) {
495
+ const type = manifest.sections[Number(index)]?.type;
496
+ const file = project.sectionFiles.find(
497
+ (section) => type !== void 0 && section.types.includes(type)
498
+ );
499
+ return file?.file ?? THEME_ENTRY;
500
+ }
501
+ if (head === "templates" && index !== void 0) {
502
+ return `theme/templates/${index}.json`;
503
+ }
504
+ if (head === "settings") {
505
+ return "theme/settings.ts";
506
+ }
507
+ return THEME_ENTRY;
508
+ }
509
+ function tokenPrefix(token) {
510
+ return `${token.slice(0, 12)}\u2026`;
511
+ }
512
+ async function themePush(directory, options = {}, reporter = processReporter) {
513
+ const env = storefrontEnv(directory, options.environment);
514
+ const token = env.MAGICSTORE_THEME_TOKEN;
515
+ if (!token) {
516
+ throw new CommandFailed(
517
+ "Set MAGICSTORE_THEME_TOKEN (the theme deploy token, mtt_\u2026, from Shop settings \u2192 Storefront API)."
518
+ );
519
+ }
520
+ if (!token.startsWith("mtt_")) {
521
+ throw new CommandFailed(
522
+ token.startsWith("msf_") ? "MAGICSTORE_THEME_TOKEN holds a storefront access token (msf_\u2026). Pushing needs the theme deploy token (mtt_\u2026)." : "MAGICSTORE_THEME_TOKEN is not a theme deploy token (mtt_\u2026)."
523
+ );
524
+ }
525
+ const shopDomain = env.MAGICSTORE_SHOP_DOMAIN;
526
+ const baseUrl = env.MAGICSTORE_API_URL;
527
+ if (!shopDomain && !baseUrl) {
528
+ throw new CommandFailed("Set MAGICSTORE_SHOP_DOMAIN or MAGICSTORE_API_URL.");
529
+ }
530
+ const started = Date.now();
531
+ const { project, manifest } = await checked(directory, options, reporter);
532
+ if (options.verbose) {
533
+ reporter.out(`checked in ${Date.now() - started} ms`);
534
+ }
535
+ const baseFetch = options.fetch ?? globalThis.fetch;
536
+ const fetch = async (input, init) => {
537
+ const request = input instanceof Request ? input : new Request(input, init);
538
+ const url = new URL(request.url);
539
+ const sent = Date.now();
540
+ const response = await baseFetch(request);
541
+ if (options.verbose) {
542
+ reporter.out(
543
+ `${request.method} ${url.origin}${url.pathname} \u2192 ${response.status} in ${Date.now() - sent} ms`
544
+ );
545
+ }
546
+ return response;
547
+ };
548
+ const client = project.client.createStorefrontClient({
549
+ ...baseUrl ? { baseUrl } : { shopDomain },
550
+ fetch
551
+ });
552
+ const role = options.role ?? "UNPUBLISHED";
553
+ if (options.verbose) {
554
+ reporter.out(`pushing as ${role} with ${tokenPrefix(token)}`);
555
+ }
556
+ try {
557
+ const { data } = await client.themePush({
558
+ query: { role },
559
+ header: { "X-Theme-Token": token },
560
+ body: manifest
561
+ });
562
+ reporter.out(
563
+ `pushed ${data.name}@${data.version} (${data.role}): ${data.sectionCount} sections`
564
+ );
565
+ for (const orphan of data.orphaned) {
566
+ reporter.err(
567
+ `templates.${orphan.template}.sections.${orphan.sectionId}: ORPHANED (warning) the stored section of type "${orphan.type}" is hidden: this theme no longer declares that type.`
568
+ );
569
+ }
570
+ return { pushed: data, manifest };
571
+ } catch (error) {
572
+ if (!(error instanceof project.client.MagicStoreError)) {
573
+ throw error;
574
+ }
575
+ for (const fieldError of error.errors) {
576
+ reporter.err(
577
+ `${fileForField(fieldError.field, project, manifest)}: ${fieldError.field} ${fieldError.code} ${fieldError.message}`
578
+ );
579
+ }
580
+ const hint = error.code === "THEME_VERSION_CONFLICT" ? ` Bump version in ${THEME_ENTRY} (it is ${manifest.version}).` : "";
581
+ const message = `themePush failed: ${error.code} ${error.detail}${hint} (request ${error.requestId})`;
582
+ throw new CommandFailed(message);
583
+ }
584
+ }
585
+ function themeDev(directory, options = {}, reporter = processReporter) {
586
+ let timer;
587
+ let running = false;
588
+ let again = false;
589
+ let lastTypes = null;
590
+ const cycle = async () => {
591
+ if (running) {
592
+ again = true;
593
+ return;
594
+ }
595
+ running = true;
596
+ try {
597
+ const silent = { out: () => {
598
+ }, err: reporter.err };
599
+ const { pushed, manifest } = await themePush(
600
+ directory,
601
+ { ...options, role: "DEVELOPMENT" },
602
+ silent
603
+ );
604
+ const types = manifest.sections.map((section) => section.type);
605
+ const changes = lastTypes === null ? [] : [
606
+ ...types.filter((type) => !lastTypes.includes(type)).map((type) => `+${type}`),
607
+ ...lastTypes.filter((type) => !types.includes(type)).map((type) => `-${type}`)
608
+ ];
609
+ lastTypes = types;
610
+ reporter.out(
611
+ [`pushed v${pushed.version}`, `${pushed.sectionCount} sections`, ...changes].join(", ")
612
+ );
613
+ } catch (error) {
614
+ reporter.err(error instanceof Error ? error.message : String(error));
615
+ } finally {
616
+ running = false;
617
+ if (again) {
618
+ again = false;
619
+ void cycle();
620
+ }
621
+ }
622
+ };
623
+ const watcher = watch(resolve5(directory, "theme"), { recursive: true }, () => {
624
+ clearTimeout(timer);
625
+ timer = setTimeout(() => void cycle(), 500);
626
+ });
627
+ void cycle();
628
+ reporter.out("watching theme/ \u2014 pushing to DEVELOPMENT on every change");
629
+ return {
630
+ close() {
631
+ clearTimeout(timer);
632
+ watcher.close();
633
+ }
634
+ };
635
+ }
636
+
178
637
  // src/index.ts
179
638
  var HELP = `Usage: create-magic-storefront [directory] [options]
180
639
  create-magic-storefront check [directory]
640
+ create-magic-storefront theme build|check|push|dev [directory] [options]
181
641
 
182
642
  Scaffolds a Next.js storefront on the MagicStore Storefront API v2.
183
643
 
184
644
  check verifies that a storefront uses the SDK only through its public entry points
185
645
  (no deep @magicstoreai/* imports, no hand-written /api/v2/storefront/... calls).
186
646
 
647
+ theme check loads theme/index.ts and checks its sections and templates; theme build also writes
648
+ .magicstore/theme.json; theme push sends it to the shop (MAGICSTORE_THEME_TOKEN, mtt_\u2026, and
649
+ MAGICSTORE_SHOP_DOMAIN or MAGICSTORE_API_URL, from the environment or .env.local); theme dev pushes
650
+ to the DEVELOPMENT role on every change under theme/.
651
+
187
652
  Options:
188
653
  --shop <domain> the shop's storefront domain (shop.example.uz)
189
654
  --token <token> a storefront access token that lists this site's origin (optional)
190
655
  --locale <locale> the language pages render in (default: ru)
656
+ --strict theme: copy hardcoded in a section component is an error, not a warning
657
+ --locales <list> theme: locales every text default needs (default: en,ru,uz)
658
+ --publish theme push: make the version live at once (default: unpublished)
659
+ --role <role> theme push: published, unpublished or development
660
+ --verbose theme push: print the request URL and timings
191
661
  -h, --help show this help
192
662
  `;
663
+ var ROLES = {
664
+ published: "PUBLISHED",
665
+ unpublished: "UNPUBLISHED",
666
+ development: "DEVELOPMENT"
667
+ };
668
+ async function theme(command, directory, values) {
669
+ const role = values.publish ? "PUBLISHED" : values.role ? ROLES[values.role.toLowerCase()] : void 0;
670
+ if (values.role && role === void 0) {
671
+ throw new Error(`--role must be one of ${Object.keys(ROLES).join(", ")}.`);
672
+ }
673
+ const options = {
674
+ ...values.strict ? { strict: true } : {},
675
+ ...values.locales ? { locales: values.locales.split(",").map((locale) => locale.trim()) } : {},
676
+ ...values.verbose ? { verbose: true } : {},
677
+ ...role ? { role } : {}
678
+ };
679
+ switch (command) {
680
+ case "check":
681
+ await themeCheck(directory, options);
682
+ return;
683
+ case "build":
684
+ await themeBuild(directory, options);
685
+ return;
686
+ case "push":
687
+ await themePush(directory, options);
688
+ return;
689
+ case "dev":
690
+ themeDev(directory, options);
691
+ return;
692
+ default:
693
+ throw new Error("Usage: create-magic-storefront theme build|check|push|dev [directory]");
694
+ }
695
+ }
193
696
  function check(directory) {
194
697
  const violations = checkPublicApi(directory);
195
698
  if (violations.length > 0) {
@@ -206,6 +709,11 @@ async function main() {
206
709
  shop: { type: "string" },
207
710
  token: { type: "string" },
208
711
  locale: { type: "string" },
712
+ strict: { type: "boolean" },
713
+ locales: { type: "string" },
714
+ publish: { type: "boolean" },
715
+ role: { type: "string" },
716
+ verbose: { type: "boolean" },
209
717
  help: { type: "boolean", short: "h" }
210
718
  }
211
719
  });
@@ -217,6 +725,10 @@ async function main() {
217
725
  check(positionals[1] ?? ".");
218
726
  return;
219
727
  }
728
+ if (positionals[0] === "theme") {
729
+ await theme(positionals[1], positionals[2] ?? ".", values);
730
+ return;
731
+ }
220
732
  const interactive = process.stdin.isTTY === true;
221
733
  const ask = async (question, fallback) => {
222
734
  if (!interactive) {
@@ -229,21 +741,23 @@ async function main() {
229
741
  };
230
742
  const directory = positionals[0] ?? await ask("Directory", "magic-storefront");
231
743
  const shopDomain = values.shop ?? await ask("Shop domain", "");
232
- const root = resolve3(dirname(fileURLToPath(import.meta.url)), "..");
233
- const own = JSON.parse(readFileSync3(resolve3(root, "package.json"), "utf8"));
744
+ const root = resolve6(dirname(fileURLToPath(import.meta.url)), "..");
745
+ const own = JSON.parse(readFileSync5(resolve6(root, "package.json"), "utf8"));
234
746
  const { directory: created } = scaffold({
235
747
  directory,
236
- templateDirectory: resolve3(root, "template"),
237
- // The SDK is released together with this CLI: same version.
748
+ templateDirectory: resolve6(root, "template"),
749
+ // The SDK is released together with this CLI: same version. The storefront keeps the CLI for
750
+ // its `theme` commands.
238
751
  sdkVersions: {
239
752
  "@magicstoreai/hydrogen": own.version,
240
- "@magicstoreai/storefront-client": own.version
753
+ "@magicstoreai/storefront-client": own.version,
754
+ "create-magic-storefront": own.version
241
755
  },
242
756
  shopDomain: shopDomain || void 0,
243
757
  storefrontToken: values.token,
244
758
  locale: values.locale
245
759
  });
246
- const where = relative2(process.cwd(), created) || ".";
760
+ const where = relative3(process.cwd(), created) || ".";
247
761
  process.stdout.write(`
248
762
  Created ${where}.
249
763
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-magic-storefront",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Scaffold a Next.js storefront on the MagicStore Storefront API v2",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,8 +14,14 @@
14
14
  "engines": {
15
15
  "node": ">=22.12"
16
16
  },
17
+ "dependencies": {
18
+ "esbuild": "^0.27.0"
19
+ },
17
20
  "devDependencies": {
21
+ "@magicstoreai/hydrogen": "0.4.0",
22
+ "@magicstoreai/storefront-client": "0.4.0",
18
23
  "@types/node": "^26.6.2",
24
+ "react": "^19.3.0",
19
25
  "tsup": "^8.5.1",
20
26
  "typescript": "5.9.3",
21
27
  "vitest": "^5.0.1"