@zohofinance/commerce 2.1.0 → 2.2.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 (48) hide show
  1. package/VALIDATIONS.md +122 -0
  2. package/package.json +2 -2
  3. package/public/dev-error.liquid +49 -0
  4. package/ts-dist/commands/build-theme.d.ts.map +1 -1
  5. package/ts-dist/commands/build-theme.js +38 -11
  6. package/ts-dist/commands/serve-theme.d.ts.map +1 -1
  7. package/ts-dist/commands/serve-theme.js +213 -49
  8. package/ts-dist/index.js +0 -0
  9. package/ts-dist/mocks/global-objects.d.ts.map +1 -1
  10. package/ts-dist/mocks/global-objects.js +1 -0
  11. package/ts-dist/mocks/init-theme/i18n/en.schema.json +2 -1
  12. package/ts-dist/mocks/init-theme/sections/footer.liquid +3 -1
  13. package/ts-dist/mocks/init-theme/sections/header-topbar.liquid +3 -1
  14. package/ts-dist/mocks/init-theme/sections/product-collection.liquid +3 -1
  15. package/ts-dist/mocks/init-theme/sections/promo-banner.liquid +5 -1
  16. package/ts-dist/mocks/reviews.d.ts +6 -0
  17. package/ts-dist/mocks/reviews.d.ts.map +1 -0
  18. package/ts-dist/mocks/reviews.js +52 -0
  19. package/ts-dist/routes/storefront-routes.js +1 -1
  20. package/ts-dist/utils/api-handler.d.ts +6 -1
  21. package/ts-dist/utils/api-handler.d.ts.map +1 -1
  22. package/ts-dist/utils/api-handler.js +91 -35
  23. package/ts-dist/utils/error-report.d.ts +1 -1
  24. package/ts-dist/utils/error-report.d.ts.map +1 -1
  25. package/ts-dist/utils/error-report.js +58 -18
  26. package/ts-dist/utils/load-theme-globals.d.ts +1 -1
  27. package/ts-dist/utils/load-theme-globals.d.ts.map +1 -1
  28. package/ts-dist/utils/load-theme-globals.js +42 -2
  29. package/ts-dist/utils/resolve-schema-settings.d.ts.map +1 -1
  30. package/ts-dist/utils/resolve-schema-settings.js +0 -1
  31. package/ts-dist/utils/resolve-schema-translations.d.ts +1 -0
  32. package/ts-dist/utils/resolve-schema-translations.d.ts.map +1 -1
  33. package/ts-dist/utils/resolve-schema-translations.js +3 -0
  34. package/ts-dist/utils/schema-validation.d.ts +34 -0
  35. package/ts-dist/utils/schema-validation.d.ts.map +1 -0
  36. package/ts-dist/utils/schema-validation.js +544 -0
  37. package/ts-dist/utils/serve-theme-utils.d.ts +1 -1
  38. package/ts-dist/utils/serve-theme-utils.d.ts.map +1 -1
  39. package/ts-dist/utils/serve-theme-utils.js +1 -1
  40. package/ts-dist/utils/template-reference-validation.d.ts +11 -0
  41. package/ts-dist/utils/template-reference-validation.d.ts.map +1 -0
  42. package/ts-dist/utils/template-reference-validation.js +466 -0
  43. package/ts-dist/utils/theme-block-schema.d.ts +5 -0
  44. package/ts-dist/utils/theme-block-schema.d.ts.map +1 -0
  45. package/ts-dist/utils/theme-block-schema.js +35 -0
  46. package/ts-dist/utils/theme-validation.d.ts +8 -1
  47. package/ts-dist/utils/theme-validation.d.ts.map +1 -1
  48. package/ts-dist/utils/theme-validation.js +84 -143
@@ -13,13 +13,14 @@ import { findAvailablePort, watchFolders } from '../utils/serve-theme-utils.js';
13
13
  import { APIHandler } from '../utils/api-handler.js';
14
14
  import { loadThemeGlobals, mergePageData } from '../utils/load-theme-globals.js';
15
15
  import StackTrace from '../utils/stack-trace.js';
16
- import { validateTheme, formatValidationIssuesForConsole } from '../utils/theme-validation.js';
16
+ import { validateTheme, formatValidationIssuesForConsole, validationWarnings } from '../utils/theme-validation.js';
17
17
  import { buildValidationErrorReport, buildRuntimeErrorReport, renderDevErrorPage } from '../utils/error-report.js';
18
18
  import { STOREFRONT_ROUTES, resolveApiPath, resolvePageTemplate } from '../routes/storefront-routes.js';
19
19
  import { resolveSchemaSettings } from '../utils/resolve-schema-settings.js';
20
- import { resolveSchemaTranslations as resolveSchemaT, resolveSchemaName } from '../utils/resolve-schema-translations.js';
20
+ import { resolveSchemaTranslations as resolveSchemaT, resolveSchemaName, invalidateSchemaLocaleCache } from '../utils/resolve-schema-translations.js';
21
21
  import { prepareSectionData } from '../utils/prepare-section-data.js';
22
22
  import { buildSectionComponentAttr } from '../utils/editor-attributes.js';
23
+ import { withThemeBlockSchema } from '../utils/theme-block-schema.js';
23
24
  function normalizeStoreUrl(input) {
24
25
  if (!input)
25
26
  return undefined;
@@ -39,8 +40,6 @@ function normalizeStoreUrl(input) {
39
40
  async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true, storeUrl) {
40
41
  const liveReloadPort = await findAvailablePort();
41
42
  const stackTrace = new StackTrace();
42
- const themeGlobals = loadThemeGlobals();
43
- let runtimeGlobals = themeGlobals;
44
43
  const normalizedStoreUrl = normalizeStoreUrl(storeUrl);
45
44
  const shouldUseMock = useMock || !normalizedStoreUrl;
46
45
  const cwd = process.cwd();
@@ -50,7 +49,11 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
50
49
  let validationResult = await validateTheme(cwd, { mode: 'dev' });
51
50
  if (!validationResult.valid) {
52
51
  console.error(chalk.yellow('\nTheme validation failed. Pages will show validation errors until the issues are fixed.'));
53
- console.error(chalk.red(formatValidationIssuesForConsole(validationResult.issues)));
52
+ console.error(formatValidationIssuesForConsole(validationResult.issues));
53
+ }
54
+ else if (validationWarnings(validationResult.issues).length > 0) {
55
+ console.log(chalk.yellow('\nTheme validation passed with warnings. Preview is still available.'));
56
+ console.log(formatValidationIssuesForConsole(validationResult.issues));
54
57
  }
55
58
  else {
56
59
  console.log(chalk.green('Theme validation passed.'));
@@ -59,18 +62,78 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
59
62
  useMock: shouldUseMock,
60
63
  baseURL: normalizedStoreUrl ?? ''
61
64
  });
62
- if (!shouldUseMock && normalizedStoreUrl) {
65
+ // Page-specific keys of the live /home response; they must not leak into other pages.
66
+ const PAGE_SPECIFIC_KEYS = [
67
+ 'product',
68
+ 'products',
69
+ 'collection',
70
+ 'collections',
71
+ 'category',
72
+ 'categories',
73
+ 'blog',
74
+ 'blogs',
75
+ 'page',
76
+ 'page_context',
77
+ 'cart',
78
+ 'checkout',
79
+ 'order',
80
+ 'comments',
81
+ 'page_title',
82
+ 'seo'
83
+ ];
84
+ let liveGlobals = null;
85
+ async function ensureLiveGlobals() {
86
+ if (shouldUseMock || liveGlobals)
87
+ return;
63
88
  try {
64
- const homeData = await apiHandler.fetch('/home');
65
- runtimeGlobals = mergePageData(themeGlobals, homeData);
89
+ const homeData = {
90
+ ...(await apiHandler.fetch('/home'))
91
+ };
92
+ for (const key of PAGE_SPECIFIC_KEYS) {
93
+ delete homeData[key];
94
+ }
95
+ liveGlobals = homeData;
66
96
  }
67
97
  catch (e) {
68
- console.warn('Could not load live storefront globals. Using local theme globals instead.', e);
98
+ console.warn('Could not load live storefront globals. Will retry on the next request.', e);
69
99
  }
70
100
  }
71
- const engine = new TemplateEngine({ globals: runtimeGlobals }, stackTrace);
101
+ // Rebuilt on every access so edits to settings/theme.json show up without a restart.
102
+ function getRuntimeGlobals() {
103
+ const themeGlobals = loadThemeGlobals(!shouldUseMock);
104
+ return liveGlobals ? mergePageData(themeGlobals, liveGlobals) : themeGlobals;
105
+ }
106
+ await ensureLiveGlobals();
107
+ const productHandleById = new Map();
108
+ function indexProductHandles(value, depth = 0) {
109
+ if (depth > 8 || !value || typeof value !== 'object')
110
+ return;
111
+ if (Array.isArray(value)) {
112
+ for (const item of value)
113
+ indexProductHandles(item, depth + 1);
114
+ return;
115
+ }
116
+ const record = value;
117
+ const { id, handle } = record;
118
+ if ((typeof id === 'string' || typeof id === 'number') &&
119
+ typeof handle === 'string' &&
120
+ handle) {
121
+ productHandleById.set(String(id), handle);
122
+ }
123
+ for (const nested of Object.values(record)) {
124
+ indexProductHandles(nested, depth + 1);
125
+ }
126
+ }
127
+ function resolveProductPath(productId) {
128
+ return productHandleById.get(productId) ?? productId;
129
+ }
130
+ const engine = new TemplateEngine({ globals: getRuntimeGlobals() }, stackTrace);
72
131
  if (!shouldUseMock && normalizedStoreUrl) {
73
- engine.fetcher = (url) => apiHandler.fetch(url);
132
+ engine.fetcher = async (url) => {
133
+ const data = await apiHandler.fetch(url);
134
+ indexProductHandles(data);
135
+ return data;
136
+ };
74
137
  }
75
138
  function parseSectionsParam(req) {
76
139
  const raw = req.body?.sections ?? req.query?.sections;
@@ -144,11 +207,19 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
144
207
  }
145
208
  return sections;
146
209
  }
210
+ // Declared before revalidateTheme so a file change can reset it.
211
+ const sectionSchemaCache = new Map();
147
212
  async function revalidateTheme() {
213
+ sectionSchemaCache.clear();
214
+ invalidateSchemaLocaleCache();
148
215
  validationResult = await validateTheme(cwd, { mode: 'dev' });
149
216
  if (!validationResult.valid) {
150
217
  console.error(chalk.yellow('\nTheme validation failed after a file change.'));
151
- console.error(chalk.red(formatValidationIssuesForConsole(validationResult.issues)));
218
+ console.error(formatValidationIssuesForConsole(validationResult.issues));
219
+ }
220
+ else if (validationWarnings(validationResult.issues).length > 0) {
221
+ console.log(chalk.yellow('\nTheme validation passed with warnings after a file change.'));
222
+ console.log(formatValidationIssuesForConsole(validationResult.issues));
152
223
  }
153
224
  else {
154
225
  console.log(chalk.green('Theme validation passed.'));
@@ -158,9 +229,24 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
158
229
  const report = buildValidationErrorReport(validationResult, cwd);
159
230
  return renderDevErrorPage(report);
160
231
  }
161
- async function fetchPageData(apiPath) {
162
- const pageData = await apiHandler.fetch(apiPath);
163
- return mergePageData(runtimeGlobals, pageData);
232
+ async function fetchPageData(apiPath, query = '') {
233
+ await ensureLiveGlobals();
234
+ // Sort, pagination and filter params must reach the live store.
235
+ const pageData = await apiHandler.fetch(query ? `${apiPath}?${query}` : apiPath);
236
+ indexProductHandles(pageData);
237
+ return mergePageData(getRuntimeGlobals(), pageData);
238
+ }
239
+ function pageQueryString(req) {
240
+ const params = new URLSearchParams();
241
+ for (const [key, value] of Object.entries(req.query)) {
242
+ if (key === 'format' || key === 'sections')
243
+ continue;
244
+ for (const v of Array.isArray(value) ? value : [value]) {
245
+ if (typeof v === 'string')
246
+ params.append(key, v);
247
+ }
248
+ }
249
+ return params.toString();
164
250
  }
165
251
  async function renderPage(pathName, data, req) {
166
252
  if (req?.query.format === 'json') {
@@ -187,7 +273,7 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
187
273
  async function handlePageRoute(routePageTemplate, apiPath, req, res, next) {
188
274
  try {
189
275
  const pageTemplate = resolvePageTemplate(routePageTemplate);
190
- const data = await fetchPageData(apiPath);
276
+ const data = await fetchPageData(apiPath, pageQueryString(req));
191
277
  // Always render in editor mode so sections get data-zcommerce-component attributes
192
278
  engine.engine.__editorMode = true;
193
279
  engine.engine.__editorJsonType = 'template';
@@ -208,7 +294,7 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
208
294
  else {
209
295
  html += bridgeTag;
210
296
  }
211
- res.status(result.status ?? 200).send(html);
297
+ res.status(result.status ?? 200).set('Cache-Control', 'no-store').send(html);
212
298
  }
213
299
  catch (error) {
214
300
  engine.engine.__editorMode = false;
@@ -236,11 +322,14 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
236
322
  app.engine('liquid', engine.express());
237
323
  app.set('views', ['./pages']);
238
324
  app.set('view engine', 'liquid');
239
- if (!livereloadOff) {
325
+ if (livereloadOff) {
326
+ watchFolders(undefined, revalidateTheme);
327
+ }
328
+ else {
240
329
  app.use(connectLiveReload({ port: liveReloadPort }));
241
330
  const liveReloadServer = livereload.createServer({ port: liveReloadPort });
331
+ watchFolders(liveReloadServer, revalidateTheme);
242
332
  liveReloadServer.server.once('connection', () => {
243
- watchFolders(liveReloadServer, revalidateTheme);
244
333
  setTimeout(() => {
245
334
  liveReloadServer.refresh('/');
246
335
  }, 100);
@@ -250,12 +339,62 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
250
339
  app.get('/', (_req, res) => {
251
340
  res.redirect('/home');
252
341
  });
342
+ const PREDICTIVE_SEARCH_SECTION = 'search-result';
343
+ function withSearchContext(data, req) {
344
+ const existing = data.search && typeof data.search === 'object' && !Array.isArray(data.search)
345
+ ? { ...data.search }
346
+ : {};
347
+ if (existing.products === undefined) {
348
+ existing.products = data.products ?? [];
349
+ }
350
+ if (existing.q === undefined) {
351
+ existing.q = typeof req.query.q === 'string' ? req.query.q : '';
352
+ }
353
+ return { ...data, search: existing };
354
+ }
355
+ async function handlePredictiveSearch(req, res, next) {
356
+ try {
357
+ const requested = parseSectionsParam(req);
358
+ const sectionNames = requested.length > 0 ? requested : [PREDICTIVE_SEARCH_SECTION];
359
+ const data = await fetchPageData('/search', pageQueryString(req));
360
+ const sections = await renderSectionsForResponse(sectionNames, withSearchContext(data, req));
361
+ res.json({ sections });
362
+ }
363
+ catch (error) {
364
+ next(error);
365
+ }
366
+ }
367
+ app.get('/home/search', handlePredictiveSearch);
368
+ // /search is the search page as well, so only a sections param marks it as
369
+ // an API call; a plain navigation falls through to the page route below.
370
+ app.get('/search', (req, res, next) => {
371
+ if (parseSectionsParam(req).length === 0) {
372
+ next();
373
+ return;
374
+ }
375
+ return handlePredictiveSearch(req, res, next);
376
+ });
253
377
  for (const route of STOREFRONT_ROUTES) {
254
378
  app.get(route.expressPath, (req, res, next) => {
255
379
  const apiPath = resolveApiPath(route, req.params);
256
380
  return handlePageRoute(route.pageTemplate, apiPath, req, res, next);
257
381
  });
258
382
  }
383
+ // Live stores link to singular /category/<handle> and /collection/<handle> URLs.
384
+ const ROUTE_ALIASES = [
385
+ ['/category/:category_name', '/categories/:category_name'],
386
+ ['/collection/:collection_name', '/collections/:collection_name'],
387
+ ['/product/:product_id', '/products/:product_id']
388
+ ];
389
+ for (const [aliasPath, targetPath] of ROUTE_ALIASES) {
390
+ const route = STOREFRONT_ROUTES.find((r) => r.expressPath === targetPath);
391
+ if (!route)
392
+ continue;
393
+ app.get(aliasPath, (req, res, next) => {
394
+ const apiPath = resolveApiPath(route, req.params);
395
+ return handlePageRoute(route.pageTemplate, apiPath, req, res, next);
396
+ });
397
+ }
259
398
  app.post('/addtocart', upload.none(), async (req, res, next) => {
260
399
  try {
261
400
  const response = (await apiHandler.addToCart(req.body));
@@ -263,7 +402,7 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
263
402
  if (sectionNames.length > 0) {
264
403
  const cartData = await resolveCartForSections(response);
265
404
  const sections = await renderSectionsForResponse(sectionNames, {
266
- ...runtimeGlobals,
405
+ ...getRuntimeGlobals(),
267
406
  cart: cartData
268
407
  });
269
408
  res.send({ ...response, sections });
@@ -283,7 +422,7 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
283
422
  if (sectionNames.length > 0) {
284
423
  const cartData = await resolveCartForSections(response);
285
424
  const sections = await renderSectionsForResponse(sectionNames, {
286
- ...runtimeGlobals,
425
+ ...getRuntimeGlobals(),
287
426
  cart: cartData
288
427
  });
289
428
  res.send({ ...response, sections });
@@ -303,7 +442,7 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
303
442
  if (sectionNames.length > 0) {
304
443
  const cartData = await resolveCartForSections(response);
305
444
  const sections = await renderSectionsForResponse(sectionNames, {
306
- ...runtimeGlobals,
445
+ ...getRuntimeGlobals(),
307
446
  cart: cartData
308
447
  });
309
448
  res.send({ ...response, sections });
@@ -396,18 +535,14 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
396
535
  const sectionNames = parseSectionsParam(req);
397
536
  if (sectionNames.length === 0) {
398
537
  res.status(400).json({
399
- code: 1,
400
538
  message: 'Missing sections query param',
401
539
  sections: {}
402
540
  });
403
541
  return;
404
542
  }
405
- const data = await fetchPageData(`/products/${req.params.product_id}`);
543
+ const data = await fetchPageData(`/products/${encodeURIComponent(resolveProductPath(req.params.product_id))}`);
406
544
  const sections = await renderSectionsForResponse(sectionNames, data);
407
- res.json({
408
- code: 0,
409
- sections
410
- });
545
+ res.json({ sections });
411
546
  }
412
547
  catch (error) {
413
548
  next(error);
@@ -415,7 +550,34 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
415
550
  });
416
551
  app.get('/products/:product_id/reviews', async (req, res, next) => {
417
552
  try {
418
- res.json(await apiHandler.getProductReviews(req.params.product_id));
553
+ const params = new URLSearchParams();
554
+ const response = (await apiHandler.getProductReviews(req.params.product_id, params.toString()));
555
+ res.json(response);
556
+ return;
557
+ }
558
+ catch (error) {
559
+ next(error);
560
+ }
561
+ });
562
+ app.post('/products/:product_id/reviews', async (req, res, next) => {
563
+ try {
564
+ res.json(await apiHandler.submitProductReview('POST', req.params.product_id, req.body));
565
+ }
566
+ catch (error) {
567
+ next(error);
568
+ }
569
+ });
570
+ app.put('/products/:product_id/reviews', async (req, res, next) => {
571
+ try {
572
+ res.json(await apiHandler.submitProductReview('PUT', req.params.product_id, req.body));
573
+ }
574
+ catch (error) {
575
+ next(error);
576
+ }
577
+ });
578
+ app.get('/editreview/:product_id', async (req, res, next) => {
579
+ try {
580
+ res.json(await apiHandler.getReviewForEdit(req.params.product_id));
419
581
  }
420
582
  catch (error) {
421
583
  next(error);
@@ -437,7 +599,7 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
437
599
  if (sectionNames.length > 0) {
438
600
  const cartData = await resolveCartForSections(response);
439
601
  const sections = await renderSectionsForResponse(sectionNames, {
440
- ...runtimeGlobals,
602
+ ...getRuntimeGlobals(),
441
603
  cart: cartData
442
604
  });
443
605
  res.send({ ...response, sections });
@@ -459,7 +621,7 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
459
621
  const cartData = await resolveCartForSections(response);
460
622
  const saveForLaterData = resolveSaveForLaterForSections(response);
461
623
  const sectionData = {
462
- ...runtimeGlobals,
624
+ ...getRuntimeGlobals(),
463
625
  cart: cartData
464
626
  };
465
627
  if (saveForLaterData) {
@@ -485,7 +647,7 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
485
647
  const cartData = await resolveCartForSections(response);
486
648
  const saveForLaterData = resolveSaveForLaterForSections(response);
487
649
  const sectionData = {
488
- ...runtimeGlobals,
650
+ ...getRuntimeGlobals(),
489
651
  cart: cartData
490
652
  };
491
653
  if (saveForLaterData) {
@@ -511,7 +673,7 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
511
673
  const cartData = await resolveCartForSections(response);
512
674
  const saveForLaterData = resolveSaveForLaterForSections(response);
513
675
  const sectionData = {
514
- ...runtimeGlobals,
676
+ ...getRuntimeGlobals(),
515
677
  cart: cartData
516
678
  };
517
679
  if (saveForLaterData) {
@@ -537,7 +699,7 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
537
699
  const cartData = await resolveCartForSections(response);
538
700
  const saveForLaterData = resolveSaveForLaterForSections(response);
539
701
  const sectionData = {
540
- ...runtimeGlobals,
702
+ ...getRuntimeGlobals(),
541
703
  cart: cartData
542
704
  };
543
705
  if (saveForLaterData) {
@@ -804,8 +966,7 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
804
966
  console.warn('[dev] Failed to persist full JSON:', e);
805
967
  }
806
968
  }
807
- // Cache parsed section schemas (type → { name, blocks }) to avoid re-reading files.
808
- const sectionSchemaCache = new Map();
969
+ // sectionSchemaCache (declared above) caches parsed section schemas (type → { name, blocks }).
809
970
  function getSectionSchema(sectionType) {
810
971
  if (sectionSchemaCache.has(sectionType))
811
972
  return sectionSchemaCache.get(sectionType);
@@ -901,11 +1062,15 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
901
1062
  catch { /* skip invalid schema */ }
902
1063
  }
903
1064
  const resolved = resolveSchemaT({ settings: schema.settings || [], name: schema.name || sectionType, blocks: schema.blocks || [] }, cwd);
904
- const blocks = (resolved.blocks || []).map((b) => ({
905
- ...b,
906
- id: `setting_section_${sectionType}.${b.type}`,
907
- settings: b.settings || [],
908
- }));
1065
+ const blocks = (schema.blocks || []).map((rawBlock) => {
1066
+ const merged = withThemeBlockSchema(cwd, rawBlock, rawBlock.type) ?? rawBlock;
1067
+ const b = resolveSchemaT(merged, cwd);
1068
+ return {
1069
+ ...b,
1070
+ id: `setting_section_${sectionType}.${b.type}`,
1071
+ settings: b.settings || [],
1072
+ };
1073
+ });
909
1074
  return {
910
1075
  id: `setting_section_${sectionType}`,
911
1076
  name: resolved.name || sectionType,
@@ -1005,11 +1170,9 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
1005
1170
  || STOREFRONT_ROUTES[0];
1006
1171
  const apiPath = resolveApiPath(route, {});
1007
1172
  const data = await fetchPageData(apiPath);
1008
- // Temporarily override runtimeGlobals.settings so the engine renders with
1009
- // the new values. Restored in the finally block regardless of outcome.
1010
- const savedSettings = runtimeGlobals.settings;
1173
+ // Override the settings on this request's data so the engine renders with the new values.
1011
1174
  if (settingsData)
1012
- runtimeGlobals.settings = settingsData;
1175
+ data.settings = settingsData;
1013
1176
  engineInternal.__editorMode = true;
1014
1177
  engineInternal.__editorJsonType = 'template';
1015
1178
  try {
@@ -1024,7 +1187,6 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
1024
1187
  res.json({ html });
1025
1188
  }
1026
1189
  finally {
1027
- runtimeGlobals.settings = savedSettings;
1028
1190
  engineInternal.__editorMode = false;
1029
1191
  }
1030
1192
  }
@@ -1062,7 +1224,7 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
1062
1224
  // Deep copy BEFORE any mutation (resolveSchemaSettings, prepareSectionData both mutate sectionJson).
1063
1225
  // This copy goes back to the editor and is later saved to disk — must have original unmodified URLs.
1064
1226
  const editorSectionJson = JSON.parse(JSON.stringify(sectionJson));
1065
- const data = { ...runtimeGlobals };
1227
+ const data = { ...getRuntimeGlobals() };
1066
1228
  if (schemaData?.settings && sectionJson.settings) {
1067
1229
  await resolveSchemaSettings(schemaData.settings, sectionJson.settings, data, engine.fetcher);
1068
1230
  }
@@ -1154,11 +1316,13 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
1154
1316
  const fileContent = fs.readFileSync(filePath, 'utf-8');
1155
1317
  const schemaMatch = fileContent.match(/{%\s*schema\s*%}([\s\S]*?){%\s*endschema\s*%}/);
1156
1318
  if (!schemaMatch) {
1157
- res.json({ schema: { settings: [], name: blockType } });
1319
+ const themeBlock = withThemeBlockSchema(cwd, undefined, blockType);
1320
+ res.json({ schema: resolveSchemaT({ settings: themeBlock?.settings || [], name: themeBlock?.name || blockType }, cwd) });
1158
1321
  return;
1159
1322
  }
1160
1323
  const schema = JSON.parse(schemaMatch[1].trim());
1161
- const blockSchema = (schema.blocks || []).find((b) => b.type === blockType);
1324
+ const sectionBlockEntry = (schema.blocks || []).find((b) => b.type === blockType);
1325
+ const blockSchema = withThemeBlockSchema(cwd, sectionBlockEntry, blockType);
1162
1326
  res.json({ schema: resolveSchemaT({ settings: blockSchema?.settings || [], name: blockSchema?.name || blockType }, cwd) });
1163
1327
  }
1164
1328
  catch (error) {
package/ts-dist/index.js CHANGED
File without changes
@@ -1 +1 @@
1
- {"version":3,"file":"global-objects.d.ts","sourceRoot":"","sources":["../../src/mocks/global-objects.ts"],"names":[],"mappings":"AAAA,KAAK,aAAa,GAAG;IACnB,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAC;CACpB,CAAC;AAEF,QAAA,MAAM,aAAa,EAAE,aAw0fpB,CAAC;AAEF,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"global-objects.d.ts","sourceRoot":"","sources":["../../src/mocks/global-objects.ts"],"names":[],"mappings":"AAAA,KAAK,aAAa,GAAG;IACnB,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAC;CACpB,CAAC;AAEF,QAAA,MAAM,aAAa,EAAE,aAy0fpB,CAAC;AAEF,eAAe,aAAa,CAAC"}
@@ -182,6 +182,7 @@ const globalObjects = {
182
182
  show_zoho_powered_by: false,
183
183
  is_product_filters_enabled: false,
184
184
  is_sorting_enabled: false,
185
+ is_product_reviews_enabled: true,
185
186
  search_box_enabled: false,
186
187
  is_footer_enabled: false,
187
188
  product_specifications_enabled: false,
@@ -43,7 +43,8 @@
43
43
  "default": "Shop Now"
44
44
  },
45
45
  "cta_url": {
46
- "label": "Button URL"
46
+ "label": "Button URL",
47
+ "default": "Shop"
47
48
  }
48
49
  }
49
50
  },
@@ -6,6 +6,8 @@
6
6
 
7
7
  {% schema %}
8
8
  {
9
- "name": "t:sections.footer.name"
9
+ "name": "t:sections.footer.name",
10
+ "available_in": { "templates": ["footer-group"] },
11
+ "presets": [{ "name": "t:sections.footer.name" }]
10
12
  }
11
13
  {% endschema %}
@@ -23,6 +23,7 @@
23
23
  {% schema %}
24
24
  {
25
25
  "name": "t:sections.header_topbar.name",
26
+ "available_in": { "templates": ["header-group"] },
26
27
  "settings": [
27
28
  {
28
29
  "type": "select",
@@ -47,6 +48,7 @@
47
48
  }
48
49
  ]
49
50
  }
50
- ]
51
+ ],
52
+ "presets": [{ "name": "t:sections.header_topbar.name" }]
51
53
  }
52
54
  {% endschema %}
@@ -25,6 +25,7 @@
25
25
  {% schema %}
26
26
  {
27
27
  "name": "t:sections.product_collection.name",
28
+ "available_in": { "templates": ["index", "collection", "category"] },
28
29
  "settings": [
29
30
  {
30
31
  "type": "collection",
@@ -38,6 +39,7 @@
38
39
  "type": "zc_theme__section-heading",
39
40
  "name": "Section Heading"
40
41
  }
41
- ]
42
+ ],
43
+ "presets": [{ "name": "t:sections.product_collection.name" }]
42
44
  }
43
45
  {% endschema %}
@@ -50,7 +50,11 @@
50
50
  {
51
51
  "type": "url",
52
52
  "id": "cta_url",
53
- "label": "t:sections.promo_banner.settings.cta_url.label"
53
+ "label": "t:sections.promo_banner.settings.cta_url.label",
54
+ "default": {
55
+ "display_name": "t:sections.promo_banner.settings.cta_url.default",
56
+ "handle": "collections/all-products"
57
+ }
54
58
  }
55
59
  ],
56
60
  "blocks": [
@@ -0,0 +1,6 @@
1
+ type ProductReviews = {
2
+ [key: string]: any;
3
+ };
4
+ declare const reviews: ProductReviews;
5
+ export default reviews;
6
+ //# sourceMappingURL=reviews.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reviews.d.ts","sourceRoot":"","sources":["../../src/mocks/reviews.ts"],"names":[],"mappings":"AAAA,KAAK,cAAc,GAAG;IACpB,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAC;CACpB,CAAC;AAIF,QAAA,MAAM,OAAO,EAAE,cAkDd,CAAC;AAEF,eAAe,OAAO,CAAC"}
@@ -0,0 +1,52 @@
1
+ // Mirrors the live /products/:product_id/reviews response so a theme renders the
2
+ // same markup with or without --storeUrl.
3
+ const reviews = {
4
+ is_reviewed: false,
5
+ allow_registered_only: false,
6
+ is_logged_in: false,
7
+ is_review_enabled: true,
8
+ reviews: [
9
+ {
10
+ id: '8858475000000094101',
11
+ user_name: 'Arun Kumar',
12
+ rating: 5,
13
+ title: 'Excellent build quality',
14
+ content: 'The product arrived well packed and works exactly as described. Battery life is great and the finish feels premium.',
15
+ last_modified_time: '14 June 2026'
16
+ },
17
+ {
18
+ id: '8858475000000094102',
19
+ user_name: 'Divya Raman',
20
+ rating: 4,
21
+ title: 'Good value for the price',
22
+ content: 'Happy with the purchase overall. Setup was simple and the sound quality is clear, though the case scratches easily.',
23
+ last_modified_time: '02 June 2026'
24
+ }
25
+ ],
26
+ user_review: null,
27
+ is_captcha_enabled: false,
28
+ average_rating: 4.5,
29
+ rating_stats: {
30
+ star_5_percentage: 50,
31
+ star_5_count: 1,
32
+ overall_average: 4.5,
33
+ star_4_percentage: 50,
34
+ star_4_count: 1,
35
+ star_3_percentage: 0,
36
+ star_3_count: 0,
37
+ star_2_percentage: 0,
38
+ star_2_count: 0,
39
+ star_1_percentage: 0,
40
+ star_1_count: 0,
41
+ total_reviews: 2
42
+ },
43
+ page_context: {
44
+ per_page: 10,
45
+ items_count: '2',
46
+ total_pages: 1,
47
+ current_page: 1,
48
+ page_param: 'page'
49
+ },
50
+ is_purchased: false
51
+ };
52
+ export default reviews;
@@ -126,7 +126,7 @@ export const STOREFRONT_ROUTES = [
126
126
  }
127
127
  ];
128
128
  export function resolveApiPath(route, params) {
129
- return route.apiPath.replace(/:([a-z_]+)/gi, (_, key) => params[key] ?? '');
129
+ return route.apiPath.replace(/:([a-z_]+)/gi, (_, key) => encodeURIComponent(params[key] ?? ''));
130
130
  }
131
131
  export function pageTemplateExists(pageTemplate) {
132
132
  return fs.existsSync(path.join(process.cwd(), 'pages', `${pageTemplate}.json`));
@@ -13,6 +13,7 @@ export declare class APIHandler {
13
13
  useMock: boolean;
14
14
  baseURL?: string;
15
15
  }): Promise<APIHandler>;
16
+ get cookieHeader(): string;
16
17
  fetch(requestUrl: string): Promise<Record<string, unknown>>;
17
18
  getMockCartData(): Record<string, unknown>;
18
19
  getMockSaveForLaterData(): Record<string, unknown>[];
@@ -27,7 +28,11 @@ export declare class APIHandler {
27
28
  wishlistAction(method: 'POST' | 'DELETE', query: string, body?: Record<string, unknown>): Promise<any>;
28
29
  getQuote(): Promise<any>;
29
30
  getProductRecommendations(productId: string): Promise<any>;
30
- getProductReviews(productId: string): Promise<any>;
31
+ getProductReviews(productId: string, query?: string): Promise<{
32
+ [key: string]: any;
33
+ }>;
34
+ submitProductReview(method: 'POST' | 'PUT', productId: string, data: Record<string, unknown>): Promise<unknown>;
35
+ getReviewForEdit(productId: string): Promise<unknown>;
31
36
  bulkAddToCart(data: Record<string, unknown>): Promise<any>;
32
37
  saveForLater(method: 'PUT' | 'DELETE', query: string): Promise<unknown>;
33
38
  moveToCart(query: string): Promise<unknown>;