create-magic-storefront 0.2.0 → 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.
- package/README.md +18 -1
- package/dist/index.js +523 -9
- package/package.json +7 -1
- package/template/.claude/skills/theme-section/SKILL.md +157 -0
- package/template/.env.example +7 -1
- package/template/.screenshots/home-1280.png +0 -0
- package/template/.screenshots/home-390.png +0 -0
- package/template/.screenshots/home-filled-1280.png +0 -0
- package/template/.screenshots/home-filled-390.png +0 -0
- package/template/.screenshots/page-1280.png +0 -0
- package/template/AGENTS.md +58 -16
- package/template/PAGES.md +107 -30
- package/template/README.md +13 -1
- package/template/_gitignore +2 -0
- package/template/app/account/page.tsx +126 -10
- package/template/app/api/magicstore/preview/route.ts +56 -0
- package/template/app/globals.css +146 -4
- package/template/app/layout.tsx +25 -6
- package/template/app/page.tsx +18 -9
- package/template/app/pages/[handle]/page.tsx +26 -11
- package/template/components/sections/collections.tsx +2 -1
- package/template/components/sections/product-shelves.tsx +2 -1
- package/template/components/telegram-shell.tsx +54 -0
- package/template/lib/api.ts +40 -10
- package/template/lib/errors.ts +21 -0
- package/template/lib/i18n.ts +33 -0
- package/template/lib/preview.ts +21 -0
- package/template/llms.txt +175 -2
- package/template/package.json +8 -3
- package/template/scripts/theme.mjs +53 -0
- package/template/theme/index.ts +53 -0
- package/template/theme/sections/collection-list.tsx +64 -0
- package/template/theme/sections/featured-collection.tsx +56 -0
- package/template/theme/sections/hero.tsx +95 -0
- package/template/theme/sections/image-with-text.tsx +71 -0
- package/template/theme/sections/page-content.tsx +45 -0
- package/template/theme/sections/platform-home.tsx +37 -0
- package/template/theme/sections/product-shelf.tsx +96 -0
- package/template/theme/sections/rich-text.tsx +43 -0
- package/template/theme/sections/testimonials.tsx +84 -0
- package/template/theme/settings.ts +19 -0
- package/template/theme/templates/index.json +9 -0
- package/template/theme/templates/page.json +6 -0
- package/template/theme/text.ts +21 -0
- 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 `/
|
|
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
|
|
5
|
-
import { dirname, relative as
|
|
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 =
|
|
233
|
-
const own = JSON.parse(
|
|
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:
|
|
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 =
|
|
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
|
+
"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"
|