@zohofinance/commerce 2.0.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 (50) 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 +414 -70
  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/editor-attributes.d.ts.map +1 -1
  24. package/ts-dist/utils/editor-attributes.js +2 -4
  25. package/ts-dist/utils/error-report.d.ts +1 -1
  26. package/ts-dist/utils/error-report.d.ts.map +1 -1
  27. package/ts-dist/utils/error-report.js +58 -18
  28. package/ts-dist/utils/load-theme-globals.d.ts +1 -1
  29. package/ts-dist/utils/load-theme-globals.d.ts.map +1 -1
  30. package/ts-dist/utils/load-theme-globals.js +42 -2
  31. package/ts-dist/utils/resolve-schema-settings.d.ts.map +1 -1
  32. package/ts-dist/utils/resolve-schema-settings.js +0 -1
  33. package/ts-dist/utils/resolve-schema-translations.d.ts +1 -0
  34. package/ts-dist/utils/resolve-schema-translations.d.ts.map +1 -1
  35. package/ts-dist/utils/resolve-schema-translations.js +3 -0
  36. package/ts-dist/utils/schema-validation.d.ts +34 -0
  37. package/ts-dist/utils/schema-validation.d.ts.map +1 -0
  38. package/ts-dist/utils/schema-validation.js +544 -0
  39. package/ts-dist/utils/serve-theme-utils.d.ts +1 -1
  40. package/ts-dist/utils/serve-theme-utils.d.ts.map +1 -1
  41. package/ts-dist/utils/serve-theme-utils.js +1 -1
  42. package/ts-dist/utils/template-reference-validation.d.ts +11 -0
  43. package/ts-dist/utils/template-reference-validation.d.ts.map +1 -0
  44. package/ts-dist/utils/template-reference-validation.js +466 -0
  45. package/ts-dist/utils/theme-block-schema.d.ts +5 -0
  46. package/ts-dist/utils/theme-block-schema.d.ts.map +1 -0
  47. package/ts-dist/utils/theme-block-schema.js +35 -0
  48. package/ts-dist/utils/theme-validation.d.ts +8 -1
  49. package/ts-dist/utils/theme-validation.d.ts.map +1 -1
  50. 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);
99
+ }
100
+ }
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);
69
125
  }
70
126
  }
71
- const engine = new TemplateEngine({ globals: runtimeGlobals }, stackTrace);
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) {
@@ -749,7 +911,7 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
749
911
  // Runtime fields added by the CLI on section/block root objects — must NOT be written to disk.
750
912
  // NOTE: display_name inside settings values (e.g. {display_name:"Full Width",value:"full"}) is
751
913
  // legitimate theme data — we only strip it at the section/block root level, not inside settings.
752
- const COMPONENT_RUNTIME_FIELDS = new Set(['id', 'display_name', 'settingid', 'zcommerce_attributes', 'block_key']);
914
+ const COMPONENT_RUNTIME_FIELDS = new Set(['id', 'display_name', 'settingid', 'zcommerce_attributes', 'block_key', 'available_sections', 'available_blocks']);
753
915
  // Clean a single section or block object: strip runtime fields from root, recurse into blocks,
754
916
  // but leave settings values entirely untouched (they may contain legitimate display_name/handle).
755
917
  function cleanComponentForDisk(obj) {
@@ -774,11 +936,11 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
774
936
  }
775
937
  return out;
776
938
  }
777
- // Clean the full page/group JSON: only touches the sections map, leaves order/name/type intact.
778
939
  function cleanFullJsonForDisk(json) {
779
940
  const sections = json.sections;
780
- if (!sections || typeof sections !== 'object' || Array.isArray(sections))
941
+ if (!sections || typeof sections !== 'object' || Array.isArray(sections)) {
781
942
  return json;
943
+ }
782
944
  const cleanedSections = {};
783
945
  for (const [sk, sv] of Object.entries(sections)) {
784
946
  cleanedSections[sk] = sv && typeof sv === 'object' && !Array.isArray(sv)
@@ -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,
@@ -991,6 +1156,45 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
991
1156
  next(error);
992
1157
  }
993
1158
  });
1159
+ // Re-renders the full page with in-memory settings override (no disk write).
1160
+ // Used by the editor when theme settings change so the preview updates immediately.
1161
+ app.post('/_editor/renderpage', async (req, res, next) => {
1162
+ const engineInternal = engine.engine;
1163
+ try {
1164
+ const body = req.body;
1165
+ const settingsData = body.settingsData;
1166
+ const pageName = body.pageName || 'index';
1167
+ const route = STOREFRONT_ROUTES.find((r) => r.pageTemplate === pageName)
1168
+ || STOREFRONT_ROUTES.find((r) => r.expressPath === `/${pageName}`)
1169
+ || STOREFRONT_ROUTES.find((r) => r.expressPath.split('/')[1]?.startsWith(pageName))
1170
+ || STOREFRONT_ROUTES[0];
1171
+ const apiPath = resolveApiPath(route, {});
1172
+ const data = await fetchPageData(apiPath);
1173
+ // Override the settings on this request's data so the engine renders with the new values.
1174
+ if (settingsData)
1175
+ data.settings = settingsData;
1176
+ engineInternal.__editorMode = true;
1177
+ engineInternal.__editorJsonType = 'template';
1178
+ try {
1179
+ const { html, error } = await engine.renderJSONTemplates({
1180
+ pathName: resolvePageTemplate(route.pageTemplate),
1181
+ data,
1182
+ });
1183
+ if (error) {
1184
+ res.status(500).json({ error: String(error) });
1185
+ return;
1186
+ }
1187
+ res.json({ html });
1188
+ }
1189
+ finally {
1190
+ engineInternal.__editorMode = false;
1191
+ }
1192
+ }
1193
+ catch (error) {
1194
+ engineInternal.__editorMode = false;
1195
+ next(error);
1196
+ }
1197
+ });
994
1198
  app.post('/_editor/rendersection', async (req, res, next) => {
995
1199
  try {
996
1200
  const body = req.body;
@@ -1020,7 +1224,7 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
1020
1224
  // Deep copy BEFORE any mutation (resolveSchemaSettings, prepareSectionData both mutate sectionJson).
1021
1225
  // This copy goes back to the editor and is later saved to disk — must have original unmodified URLs.
1022
1226
  const editorSectionJson = JSON.parse(JSON.stringify(sectionJson));
1023
- const data = { ...runtimeGlobals };
1227
+ const data = { ...getRuntimeGlobals() };
1024
1228
  if (schemaData?.settings && sectionJson.settings) {
1025
1229
  await resolveSchemaSettings(schemaData.settings, sectionJson.settings, data, engine.fetcher);
1026
1230
  }
@@ -1112,11 +1316,13 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
1112
1316
  const fileContent = fs.readFileSync(filePath, 'utf-8');
1113
1317
  const schemaMatch = fileContent.match(/{%\s*schema\s*%}([\s\S]*?){%\s*endschema\s*%}/);
1114
1318
  if (!schemaMatch) {
1115
- 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) });
1116
1321
  return;
1117
1322
  }
1118
1323
  const schema = JSON.parse(schemaMatch[1].trim());
1119
- 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);
1120
1326
  res.json({ schema: resolveSchemaT({ settings: blockSchema?.settings || [], name: blockSchema?.name || blockType }, cwd) });
1121
1327
  }
1122
1328
  catch (error) {
@@ -1127,39 +1333,106 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
1127
1333
  app.get('/_editor/availablesections', async (req, res, next) => {
1128
1334
  try {
1129
1335
  const needHtml = req.query.needHtml === 'true';
1336
+ const jsonType = req.query.jsonType || 'template';
1337
+ const pageName = req.query.pageName || 'index';
1130
1338
  const sectionsDir = path.join(cwd, 'sections');
1131
1339
  if (!fs.existsSync(sectionsDir)) {
1132
1340
  res.json([]);
1133
1341
  return;
1134
1342
  }
1343
+ let templateName;
1344
+ if (jsonType === 'header') {
1345
+ templateName = 'header-group';
1346
+ }
1347
+ else if (jsonType === 'footer') {
1348
+ templateName = 'footer-group';
1349
+ }
1350
+ else {
1351
+ templateName = pageName === 'home' ? 'index' : pageName;
1352
+ }
1353
+ let currentTemplateJson = { sections: {}, order: [] };
1354
+ try {
1355
+ let jsonPath;
1356
+ if (jsonType === 'header') {
1357
+ jsonPath = path.join(cwd, 'sections', 'header-group.json');
1358
+ }
1359
+ else if (jsonType === 'footer') {
1360
+ jsonPath = path.join(cwd, 'sections', 'footer-group.json');
1361
+ }
1362
+ else {
1363
+ jsonPath = path.join(cwd, 'pages', `${templateName}.json`);
1364
+ }
1365
+ if (fs.existsSync(jsonPath)) {
1366
+ currentTemplateJson = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
1367
+ }
1368
+ }
1369
+ catch { /* use defaults */ }
1370
+ const currentSections = (currentTemplateJson.sections || {});
1371
+ const currentOrder = (currentTemplateJson.order || []);
1372
+ const availableSections = [];
1135
1373
  const files = fs.readdirSync(sectionsDir).filter((f) => f.endsWith('.liquid'));
1136
- const sections = files.map((file) => {
1374
+ for (const file of files) {
1137
1375
  const sectionType = file.replace('.liquid', '');
1138
1376
  const content = fs.readFileSync(path.join(sectionsDir, file), 'utf-8');
1139
1377
  const schemaMatch = content.match(/{%\s*schema\s*%}([\s\S]*?){%\s*endschema\s*%}/);
1140
- let name = sectionType;
1141
- let category;
1142
- if (schemaMatch) {
1143
- try {
1144
- const schema = JSON.parse(schemaMatch[1].trim());
1145
- const resolved = resolveSchemaT({ name: schema.name || sectionType, settings: [], blocks: [] }, cwd);
1146
- name = resolved.name || sectionType;
1147
- category = schema.category;
1378
+ if (!schemaMatch)
1379
+ continue;
1380
+ let schema;
1381
+ try {
1382
+ schema = JSON.parse(schemaMatch[1].trim());
1383
+ }
1384
+ catch {
1385
+ continue;
1386
+ }
1387
+ const templates = schema?.available_in?.templates;
1388
+ if (!Array.isArray(templates) || !templates.includes(templateName)) {
1389
+ continue;
1390
+ }
1391
+ const presets = schema.presets;
1392
+ if (!Array.isArray(presets) || presets.length === 0) {
1393
+ continue;
1394
+ }
1395
+ if (typeof schema.limit === 'number') {
1396
+ let count = 0;
1397
+ for (const key of currentOrder) {
1398
+ if (currentSections[key]?.type === sectionType)
1399
+ count++;
1400
+ }
1401
+ if (count >= schema.limit) {
1402
+ continue;
1148
1403
  }
1149
- catch { /* skip */ }
1150
1404
  }
1151
- return { section_name: sectionType, name, category };
1152
- });
1405
+ for (let p = 0; p < presets.length; p++) {
1406
+ const preset = presets[p];
1407
+ if (!preset)
1408
+ continue;
1409
+ const uuid = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
1410
+ const sectionName = `${sectionType}-additional_section-${uuid}`;
1411
+ const resolved = resolveSchemaT({ name: preset.name || sectionType, settings: [], blocks: [] }, cwd);
1412
+ const displayName = resolved.name || preset.name || sectionType;
1413
+ availableSections.push({
1414
+ section_name: sectionName,
1415
+ display_name: displayName,
1416
+ name: displayName,
1417
+ preset_index: p,
1418
+ category: schema.category,
1419
+ _sectionType: sectionType,
1420
+ });
1421
+ }
1422
+ }
1153
1423
  if (!needHtml) {
1154
- res.json(sections);
1424
+ res.json(availableSections.map(({ _sectionType: _, ...s }) => s));
1155
1425
  return;
1156
1426
  }
1157
- // Render HTML for requested section names using default section data.
1158
- const sectionNames = req.query.sectionNames
1427
+ const requestedNames = req.query.sectionNames
1159
1428
  ? JSON.parse(req.query.sectionNames)
1160
- : sections.map((s) => s.section_name);
1161
- const renderedMap = await renderSectionsForResponse(sectionNames, {});
1162
- // Build CSS link tags from assets directory so previews render with theme styles.
1429
+ : availableSections.map((s) => s.section_name);
1430
+ const baseTypeFor = (name) => {
1431
+ const idx = name.indexOf('-additional_section-');
1432
+ return idx >= 0 ? name.substring(0, idx) : name;
1433
+ };
1434
+ const typesToRender = [...new Set(requestedNames.map(baseTypeFor))];
1435
+ const renderedMap = await renderSectionsForResponse(typesToRender, {});
1163
1436
  const assetsDir = path.join(cwd, 'assets');
1164
1437
  const cssLinks = fs.existsSync(assetsDir)
1165
1438
  ? fs.readdirSync(assetsDir)
@@ -1168,13 +1441,45 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
1168
1441
  .join('')
1169
1442
  : '';
1170
1443
  const cliOrigin = `http://localhost:${port}`;
1171
- const results = sectionNames.map((sectionType) => {
1172
- const entry = sections.find((s) => s.section_name === sectionType);
1173
- const sectionHtml = (renderedMap[sectionType] || '')
1444
+ const displayNameForType = new Map();
1445
+ for (const entry of availableSections) {
1446
+ if (!displayNameForType.has(entry._sectionType)) {
1447
+ displayNameForType.set(entry._sectionType, entry.display_name);
1448
+ }
1449
+ }
1450
+ const presetSettingsForType = new Map();
1451
+ for (const entry of availableSections) {
1452
+ if (!presetSettingsForType.has(entry._sectionType)) {
1453
+ presetSettingsForType.set(entry._sectionType, {});
1454
+ }
1455
+ }
1456
+ for (const [sType] of presetSettingsForType) {
1457
+ try {
1458
+ const content = fs.readFileSync(path.join(sectionsDir, `${sType}.liquid`), 'utf-8');
1459
+ const sm = content.match(/{%\s*schema\s*%}([\s\S]*?){%\s*endschema\s*%}/);
1460
+ if (sm) {
1461
+ const sc = JSON.parse(sm[1].trim());
1462
+ if (Array.isArray(sc.presets) && sc.presets[0]?.settings) {
1463
+ presetSettingsForType.set(sType, sc.presets[0].settings);
1464
+ }
1465
+ }
1466
+ }
1467
+ catch { /* skip */ }
1468
+ }
1469
+ const results = requestedNames.map((reqName) => {
1470
+ const baseType = baseTypeFor(reqName);
1471
+ const sectionHtml = (renderedMap[baseType] || '')
1174
1472
  .replace(/(['"(])\/assets\//g, `$1${cliOrigin}/assets/`)
1175
1473
  .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
1176
1474
  const html = `<!DOCTYPE html><html><head><meta charset="utf-8">${cssLinks}</head><body>${sectionHtml}</body></html>`;
1177
- return { section_name: sectionType, display_name: entry?.name || sectionType, name: entry?.name || sectionType, category: entry?.category, html };
1475
+ const displayName = displayNameForType.get(baseType) || baseType;
1476
+ const sectionJson = {
1477
+ type: baseType,
1478
+ settings: presetSettingsForType.get(baseType) || {},
1479
+ blocks: {},
1480
+ block_order: [],
1481
+ };
1482
+ return { section_name: reqName, display_name: displayName, name: displayName, sectionJson, html };
1178
1483
  });
1179
1484
  res.json(results);
1180
1485
  }
@@ -1182,6 +1487,44 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
1182
1487
  next(error);
1183
1488
  }
1184
1489
  });
1490
+ app.get('/_editor/themesettings', (req, res, next) => {
1491
+ try {
1492
+ const settingsDir = path.join(cwd, 'settings');
1493
+ let settings_schema = [];
1494
+ const schemaPath = path.join(settingsDir, 'schema.json');
1495
+ if (fs.existsSync(schemaPath)) {
1496
+ try {
1497
+ const raw = JSON.parse(fs.readFileSync(schemaPath, 'utf-8'));
1498
+ settings_schema = resolveSchemaT(raw, cwd);
1499
+ }
1500
+ catch { /* use empty */ }
1501
+ }
1502
+ let settings_data = { selected: {}, presets: {} };
1503
+ const themePath = path.join(settingsDir, 'theme.json');
1504
+ if (fs.existsSync(themePath)) {
1505
+ try {
1506
+ settings_data = JSON.parse(fs.readFileSync(themePath, 'utf-8'));
1507
+ if (!settings_data.selected)
1508
+ settings_data.selected = {};
1509
+ const { selected, presets } = settings_data;
1510
+ if (selected && typeof selected.preset_name === 'string' && presets) {
1511
+ const preset = presets[selected.preset_name];
1512
+ if (preset && typeof preset === 'object') {
1513
+ settings_data = {
1514
+ ...settings_data,
1515
+ selected: { ...preset, preset_name: selected.preset_name },
1516
+ };
1517
+ }
1518
+ }
1519
+ }
1520
+ catch { /* use defaults */ }
1521
+ }
1522
+ res.json({ settings_schema, settings_data });
1523
+ }
1524
+ catch (error) {
1525
+ next(error);
1526
+ }
1527
+ });
1185
1528
  // Persists the full editor JSON to disk (for structural changes: add/delete/reorder sections).
1186
1529
  app.post('/_editor/savejson', (req, res, next) => {
1187
1530
  try {
@@ -1238,7 +1581,8 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
1238
1581
  }
1239
1582
  res.set('Cache-Control', 'no-store').send('OK');
1240
1583
  });
1241
- app.use((err, _req, res) => {
1584
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
1585
+ app.use((err, _req, res, _next) => {
1242
1586
  if (res.headersSent) {
1243
1587
  return;
1244
1588
  }