@o2vend/theme-cli 1.0.32 → 1.0.33

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.
@@ -106,23 +106,31 @@ class DevServer {
106
106
 
107
107
  // Process CSS files through Liquid to support theme variables
108
108
  // This handles CSS files that contain Liquid syntax like {{ settings.color_primary }}
109
- this.app.get('/assets/:filename.css', async (req, res, next) => {
109
+ // Supports both root assets and subdirectories: /assets/file.css and /assets/css/file.css
110
+ this.app.get('/assets/*.css', async (req, res, next) => {
110
111
  try {
111
- // Get filename from params
112
- const filename = req.params.filename + '.css';
113
- const cssPath = path.join(this.themePath, 'assets', filename);
112
+ // Extract the relative path from /assets/ (remove leading /assets/)
113
+ const relativePath = req.path.replace(/^\/assets\//, '');
114
+ const cssPath = path.join(this.themePath, 'assets', relativePath);
115
+
116
+ // Normalize path to prevent directory traversal
117
+ const normalizedPath = path.normalize(cssPath);
118
+ const assetsDir = path.normalize(path.join(this.themePath, 'assets'));
119
+ if (!normalizedPath.startsWith(assetsDir)) {
120
+ return res.status(403).send('Invalid path');
121
+ }
114
122
 
115
- if (!fs.existsSync(cssPath)) {
123
+ if (!fs.existsSync(normalizedPath)) {
116
124
  return next();
117
125
  }
118
126
 
119
- const cssContent = fs.readFileSync(cssPath, 'utf8');
127
+ const cssContent = fs.readFileSync(normalizedPath, 'utf8');
120
128
 
121
129
  // Check if CSS contains Liquid variable syntax ({{ }})
122
130
  // Only process if it contains actual Liquid variables, not just comments
123
131
  if (cssContent.includes('{{') && cssContent.includes('settings.')) {
124
132
  try {
125
- // Load settings from theme config
133
+ // Load settings from theme config (already extracts 'current' section)
126
134
  const settings = this.loadThemeSettings();
127
135
 
128
136
  // Process through Liquid
@@ -132,14 +140,14 @@ class DevServer {
132
140
  res.setHeader('Cache-Control', 'no-cache');
133
141
  return res.send(processedCss);
134
142
  } catch (liquidError) {
135
- console.error(`[CSS] Liquid processing error for ${filename}:`, liquidError.message);
143
+ console.error(`[CSS] Liquid processing error for ${relativePath}:`, liquidError.message);
136
144
  // Fall through to serve static file
137
145
  }
138
146
  }
139
147
 
140
148
  // Serve static file if no Liquid processing needed or if processing failed
141
149
  res.setHeader('Content-Type', 'text/css; charset=utf-8');
142
- res.sendFile(cssPath);
150
+ res.sendFile(normalizedPath);
143
151
  } catch (error) {
144
152
  console.error(`[CSS] Error processing ${req.path}:`, error.message);
145
153
  next();
@@ -444,6 +452,7 @@ class DevServer {
444
452
 
445
453
  const html = await renderWithLayout(this.liquid, 'templates/index', context, this.themePath);
446
454
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
455
+ res.setHeader('Cache-Control', 'no-cache');
447
456
  res.send(html);
448
457
  } catch (error) {
449
458
  next(error);
@@ -453,7 +462,53 @@ class DevServer {
453
462
  // Products listing page
454
463
  this.app.get('/products', async (req, res, next) => {
455
464
  try {
456
- const context = await this.buildContext(req, 'products');
465
+ // Check for category filter in query parameters
466
+ const categoryFilter = req.query.category;
467
+
468
+ let context = await this.buildContext(req, 'products');
469
+
470
+ // If category filter is present, filter products by category
471
+ if (categoryFilter) {
472
+ console.log(`[PRODUCTS PAGE] Filtering by category: ${categoryFilter}`);
473
+
474
+ // Find category by name or handle (case-insensitive)
475
+ const category = context.categories?.find(c =>
476
+ (c.name && c.name.toLowerCase() === categoryFilter.toLowerCase()) ||
477
+ (c.handle && c.handle.toLowerCase() === categoryFilter.toLowerCase())
478
+ );
479
+
480
+ if (category) {
481
+ console.log(`[PRODUCTS PAGE] Found category: ${category.name} (ID: ${category.id})`);
482
+
483
+ // Filter products by categoryId
484
+ const filteredProducts = context.products.filter(p =>
485
+ p.categoryId === category.id ||
486
+ String(p.categoryId).toLowerCase() === String(category.id).toLowerCase()
487
+ );
488
+
489
+ context.products = filteredProducts;
490
+ console.log(`[PRODUCTS PAGE] Filtered products: ${filteredProducts.length} products in category`);
491
+
492
+ // Update collection context
493
+ context.collection = context.collection || {};
494
+ context.collection.title = category.name || 'Products';
495
+ context.collection.handle = category.handle || categoryFilter;
496
+ context.collection.description = category.description;
497
+ context.collection.totalProducts = filteredProducts.length;
498
+ } else {
499
+ console.warn(`[PRODUCTS PAGE] Category not found: ${categoryFilter}, showing all products`);
500
+ }
501
+ }
502
+
503
+ // Ensure all products have proper URL field if missing
504
+ context.products = (context.products || []).map(product => {
505
+ if (!product.url && !product.link) {
506
+ const handle = product.handle || product.slug || product.id;
507
+ product.url = `/products/${handle}`;
508
+ product.link = `/products/${handle}`;
509
+ }
510
+ return product;
511
+ });
457
512
 
458
513
  // DEBUG: Log context data for /products page
459
514
  console.log(`[PRODUCTS PAGE] Context summary:`);
@@ -468,8 +523,30 @@ class DevServer {
468
523
  if (!context.products || context.products.length === 0) {
469
524
  console.warn('[PRODUCTS PAGE] ⚠️ No products in context, attempting to reload...');
470
525
  try {
471
- const productsResponse = await this.apiClient.getProducts({ limit: 50, offset: 0 });
526
+ const queryParams = {};
527
+ if (categoryFilter) {
528
+ // Find category and filter by categoryId
529
+ const category = context.categories?.find(c =>
530
+ (c.name && c.name.toLowerCase() === categoryFilter.toLowerCase()) ||
531
+ (c.handle && c.handle.toLowerCase() === categoryFilter.toLowerCase())
532
+ );
533
+ if (category) {
534
+ queryParams.categoryId = category.id;
535
+ }
536
+ }
537
+ const productsResponse = await this.apiClient.getProducts({ limit: 50, offset: 0, ...queryParams });
472
538
  context.products = productsResponse.products || productsResponse.data?.products || [];
539
+
540
+ // Ensure URLs are set
541
+ context.products = context.products.map(product => {
542
+ if (!product.url && !product.link) {
543
+ const handle = product.handle || product.slug || product.id;
544
+ product.url = `/products/${handle}`;
545
+ product.link = `/products/${handle}`;
546
+ }
547
+ return product;
548
+ });
549
+
473
550
  console.log(`[PRODUCTS PAGE] ✅ Reloaded ${context.products.length} products`);
474
551
  } catch (error) {
475
552
  console.error('[PRODUCTS PAGE] ❌ Failed to load products:', error.message);
@@ -479,10 +556,14 @@ class DevServer {
479
556
  if (context.products.length > 0) {
480
557
  const sampleProduct = context.products[0];
481
558
  const hasStock = 'stock' in sampleProduct || 'quantity' in sampleProduct;
559
+ const hasImage = sampleProduct.thumbnailImage1 || sampleProduct.imageUrl || (sampleProduct.images && sampleProduct.images.length > 0);
560
+ const hasUrl = sampleProduct.url || sampleProduct.link;
482
561
  // Use nullish coalescing to show 0 values correctly
483
562
  const stockValue = sampleProduct.stock ?? sampleProduct.quantity ?? 'N/A';
484
- console.log(`[PRODUCTS PAGE DEBUG] Sample product - Title: ${sampleProduct.title || sampleProduct.name}, Has stock: ${hasStock}, Stock: ${stockValue}`);
485
- console.log(`[PRODUCTS PAGE DEBUG] Product stock details: stock=${sampleProduct.stock}, quantity=${sampleProduct.quantity}, inStock=${sampleProduct.inStock}`);
563
+ console.log(`[PRODUCTS PAGE DEBUG] Sample product - Title: ${sampleProduct.title || sampleProduct.name}`);
564
+ console.log(`[PRODUCTS PAGE DEBUG] - Has stock: ${hasStock}, Stock: ${stockValue}`);
565
+ console.log(`[PRODUCTS PAGE DEBUG] - Has image: ${hasImage}, Has URL: ${hasUrl}`);
566
+ console.log(`[PRODUCTS PAGE DEBUG] - URL: ${sampleProduct.url || sampleProduct.link || 'missing'}`);
486
567
 
487
568
  // Check a few more products to see stock distribution
488
569
  const stockCounts = context.products.slice(0, 5).map(p => p.stock ?? 0);
@@ -527,13 +608,20 @@ class DevServer {
527
608
  if (context.products && context.products.length > 0) {
528
609
  context.collection = context.collection || {};
529
610
  context.collection.products = context.products;
530
- context.collection.title = 'All Products';
531
- context.collection.handle = 'all';
532
- context.collection.totalProducts = context.products.length;
611
+ if (!context.collection.title) {
612
+ context.collection.title = categoryFilter ? `${categoryFilter} Products` : 'All Products';
613
+ }
614
+ if (!context.collection.handle) {
615
+ context.collection.handle = categoryFilter || 'all';
616
+ }
617
+ if (!context.collection.totalProducts) {
618
+ context.collection.totalProducts = context.products.length;
619
+ }
533
620
  }
534
621
 
535
622
  const html = await renderWithLayout(this.liquid, 'templates/products', context, this.themePath);
536
623
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
624
+ res.setHeader('Cache-Control', 'no-cache');
537
625
  res.send(html);
538
626
  } catch (error) {
539
627
  // If products template doesn't exist, try collections template as fallback
@@ -542,6 +630,7 @@ class DevServer {
542
630
  const context = await this.buildContext(req, 'collection');
543
631
  const html = await renderWithLayout(this.liquid, 'templates/collection', context, this.themePath);
544
632
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
633
+ res.setHeader('Cache-Control', 'no-cache');
545
634
  res.send(html);
546
635
  } catch (fallbackError) {
547
636
  next(error);
@@ -555,7 +644,46 @@ class DevServer {
555
644
  // Product detail page
556
645
  this.app.get('/products/:handle', async (req, res, next) => {
557
646
  try {
558
- const context = await this.buildContext(req, 'product', { productHandle: req.params.handle });
647
+ const handle = req.params.handle;
648
+ console.log(`[PRODUCT PAGE] Looking up product with handle: ${handle}`);
649
+
650
+ const context = await this.buildContext(req, 'product', { productHandle: handle });
651
+
652
+ // Check if product was found
653
+ if (!context.product) {
654
+ console.warn(`[PRODUCT PAGE] Product not found with handle: ${handle}`);
655
+ // Try to find product by ID if handle looks like an ID
656
+ if (handle.match(/^\d+$/) || handle.startsWith('product-')) {
657
+ const productId = handle.replace(/^product-/, '');
658
+ console.log(`[PRODUCT PAGE] Trying to find product by ID: ${productId}`);
659
+ const allProducts = context.products || [];
660
+ const productById = allProducts.find(p =>
661
+ String(p.id) === productId ||
662
+ String(p.id) === handle ||
663
+ (p.handle && p.handle.includes(productId))
664
+ );
665
+ if (productById) {
666
+ context.product = productById;
667
+ console.log(`[PRODUCT PAGE] Found product by ID: ${productById.title || productById.name}`);
668
+ }
669
+ }
670
+
671
+ // If still not found, return 404
672
+ if (!context.product) {
673
+ return res.status(404).send(`
674
+ <!DOCTYPE html>
675
+ <html>
676
+ <head><title>Product Not Found</title></head>
677
+ <body>
678
+ <h1>Product Not Found</h1>
679
+ <p>Product with handle "${handle}" was not found.</p>
680
+ <p><a href="/products">Back to Products</a></p>
681
+ </body>
682
+ </html>
683
+ `);
684
+ }
685
+ }
686
+
559
687
  // Try multiple template names: product-detail, product, product-page
560
688
  let html;
561
689
  const templateOptions = ['templates/product-detail', 'templates/product', 'templates/product-page'];
@@ -573,8 +701,10 @@ class DevServer {
573
701
  throw new Error(`Product template not found. Tried: ${templateOptions.join(', ')}`);
574
702
  }
575
703
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
704
+ res.setHeader('Cache-Control', 'no-cache');
576
705
  res.send(html);
577
706
  } catch (error) {
707
+ console.error(`[PRODUCT PAGE] Error rendering product page:`, error);
578
708
  next(error);
579
709
  }
580
710
  });
@@ -600,6 +730,7 @@ class DevServer {
600
730
  throw new Error(`Product template not found. Tried: ${templateOptions.join(', ')}`);
601
731
  }
602
732
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
733
+ res.setHeader('Cache-Control', 'no-cache');
603
734
  res.send(html);
604
735
  } catch (error) {
605
736
  next(error);
@@ -626,6 +757,7 @@ class DevServer {
626
757
  throw new Error(`Product template not found. Tried: ${templateOptions.join(', ')}`);
627
758
  }
628
759
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
760
+ res.setHeader('Cache-Control', 'no-cache');
629
761
  res.send(html);
630
762
  } catch (error) {
631
763
  next(error);
@@ -652,6 +784,7 @@ class DevServer {
652
784
  }
653
785
  }
654
786
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
787
+ res.setHeader('Cache-Control', 'no-cache');
655
788
  res.send(html);
656
789
  } catch (error) {
657
790
  next(error);
@@ -670,6 +803,39 @@ class DevServer {
670
803
  if (category) {
671
804
  context.category = category;
672
805
  context.collection = category;
806
+
807
+ // Filter products by this category
808
+ const filteredProducts = (context.products || []).filter(p =>
809
+ p.categoryId === category.id ||
810
+ String(p.categoryId).toLowerCase() === String(category.id).toLowerCase()
811
+ );
812
+
813
+ // Ensure all products have proper URL field if missing
814
+ filteredProducts.forEach(product => {
815
+ if (!product.url && !product.link) {
816
+ const handle = product.handle || product.slug || product.id;
817
+ product.url = `/products/${handle}`;
818
+ product.link = `/products/${handle}`;
819
+ }
820
+ });
821
+
822
+ context.collection.products = filteredProducts;
823
+ context.collection.totalProducts = filteredProducts.length;
824
+ context.products = filteredProducts; // Also set in products array for consistency
825
+
826
+ console.log(`[COLLECTION PAGE] Category: ${category.name}, Products: ${filteredProducts.length}`);
827
+ } else {
828
+ // No category found, use all products
829
+ context.products = (context.products || []).map(product => {
830
+ if (!product.url && !product.link) {
831
+ const handle = product.handle || product.slug || product.id;
832
+ product.url = `/products/${handle}`;
833
+ product.link = `/products/${handle}`;
834
+ }
835
+ return product;
836
+ });
837
+ context.collection = context.collection || {};
838
+ context.collection.products = context.products;
673
839
  }
674
840
 
675
841
  let html;
@@ -688,6 +854,7 @@ class DevServer {
688
854
  throw new Error(`Category/Collection template not found. Tried: ${templateOptions.join(', ')}`);
689
855
  }
690
856
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
857
+ res.setHeader('Cache-Control', 'no-cache');
691
858
  res.send(html);
692
859
  } catch (error) {
693
860
  next(error);
@@ -714,6 +881,7 @@ class DevServer {
714
881
  throw new Error(`Categories template not found. Tried: ${templateOptions.join(', ')}`);
715
882
  }
716
883
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
884
+ res.setHeader('Cache-Control', 'no-cache');
717
885
  res.send(html);
718
886
  } catch (error) {
719
887
  next(error);
@@ -730,6 +898,39 @@ class DevServer {
730
898
  if (category) {
731
899
  context.category = category;
732
900
  context.collection = category;
901
+
902
+ // Filter products by this category
903
+ const filteredProducts = (context.products || []).filter(p =>
904
+ p.categoryId === category.id ||
905
+ String(p.categoryId).toLowerCase() === String(category.id).toLowerCase()
906
+ );
907
+
908
+ // Ensure all products have proper URL field if missing
909
+ filteredProducts.forEach(product => {
910
+ if (!product.url && !product.link) {
911
+ const handle = product.handle || product.slug || product.id;
912
+ product.url = `/products/${handle}`;
913
+ product.link = `/products/${handle}`;
914
+ }
915
+ });
916
+
917
+ context.collection.products = filteredProducts;
918
+ context.collection.totalProducts = filteredProducts.length;
919
+ context.products = filteredProducts; // Also set in products array for consistency
920
+
921
+ console.log(`[CATEGORY PAGE] Category: ${category.name}, Products: ${filteredProducts.length}`);
922
+ } else {
923
+ // No category found, use all products
924
+ context.products = (context.products || []).map(product => {
925
+ if (!product.url && !product.link) {
926
+ const handle = product.handle || product.slug || product.id;
927
+ product.url = `/products/${handle}`;
928
+ product.link = `/products/${handle}`;
929
+ }
930
+ return product;
931
+ });
932
+ context.collection = context.collection || {};
933
+ context.collection.products = context.products;
733
934
  }
734
935
 
735
936
  let html;
@@ -748,6 +949,7 @@ class DevServer {
748
949
  throw new Error(`Category template not found. Tried: ${templateOptions.join(', ')}`);
749
950
  }
750
951
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
952
+ res.setHeader('Cache-Control', 'no-cache');
751
953
  res.send(html);
752
954
  } catch (error) {
753
955
  next(error);
@@ -775,6 +977,7 @@ class DevServer {
775
977
  html = this.createSimpleBrandsPage(context);
776
978
  }
777
979
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
980
+ res.setHeader('Cache-Control', 'no-cache');
778
981
  res.send(html);
779
982
  } catch (error) {
780
983
  next(error);
@@ -813,6 +1016,7 @@ class DevServer {
813
1016
  html = this.createSimpleBrandPage(context, brand);
814
1017
  }
815
1018
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
1019
+ res.setHeader('Cache-Control', 'no-cache');
816
1020
  res.send(html);
817
1021
  } catch (error) {
818
1022
  next(error);
@@ -825,6 +1029,7 @@ class DevServer {
825
1029
  const context = await this.buildContext(req, 'cart');
826
1030
  const html = await renderWithLayout(this.liquid, 'templates/cart', context, this.themePath);
827
1031
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
1032
+ res.setHeader('Cache-Control', 'no-cache');
828
1033
  res.send(html);
829
1034
  } catch (error) {
830
1035
  next(error);
@@ -837,6 +1042,7 @@ class DevServer {
837
1042
  const context = await this.buildContext(req, 'search', { query: req.query.q });
838
1043
  const html = await renderWithLayout(this.liquid, 'templates/search', context, this.themePath);
839
1044
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
1045
+ res.setHeader('Cache-Control', 'no-cache');
840
1046
  res.send(html);
841
1047
  } catch (error) {
842
1048
  next(error);
@@ -890,12 +1096,14 @@ class DevServer {
890
1096
  try {
891
1097
  const html = await renderWithLayout(this.liquid, 'templates/page', context, this.themePath);
892
1098
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
1099
+ res.setHeader('Cache-Control', 'no-cache');
893
1100
  res.send(html);
894
1101
  } catch (templateError) {
895
1102
  // If no page template, render raw HTML content within layout
896
1103
  console.log(`[PAGE] No page template found, rendering raw HTML for ${req.params.handle}`);
897
1104
  const html = this.renderLandingPage(context);
898
1105
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
1106
+ res.setHeader('Cache-Control', 'no-cache');
899
1107
  res.send(html);
900
1108
  }
901
1109
  } catch (error) {
@@ -934,6 +1142,7 @@ class DevServer {
934
1142
 
935
1143
  const html = await renderWithLayout(this.liquid, 'templates/page', context, this.themePath);
936
1144
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
1145
+ res.setHeader('Cache-Control', 'no-cache');
937
1146
  res.send(html);
938
1147
  } catch (error) {
939
1148
  next(error);
@@ -950,6 +1159,7 @@ class DevServer {
950
1159
 
951
1160
  const html = this.generateDevDashboard({ templates, sections, widgets, snippets });
952
1161
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
1162
+ res.setHeader('Cache-Control', 'no-cache');
953
1163
  res.send(html);
954
1164
  } catch (error) {
955
1165
  next(error);
@@ -974,6 +1184,7 @@ class DevServer {
974
1184
  backUrl: '/dev'
975
1185
  });
976
1186
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
1187
+ res.setHeader('Cache-Control', 'no-cache');
977
1188
  res.send(html);
978
1189
  } catch (error) {
979
1190
  next(error);
@@ -1033,6 +1244,7 @@ class DevServer {
1033
1244
  backUrl: '/dev'
1034
1245
  });
1035
1246
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
1247
+ res.setHeader('Cache-Control', 'no-cache');
1036
1248
  res.send(html);
1037
1249
  } catch (error) {
1038
1250
  next(error);
@@ -1081,6 +1293,7 @@ class DevServer {
1081
1293
  backUrl: '/dev'
1082
1294
  });
1083
1295
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
1296
+ res.setHeader('Cache-Control', 'no-cache');
1084
1297
  res.send(html);
1085
1298
  } catch (error) {
1086
1299
  next(error);
@@ -1118,12 +1331,122 @@ class DevServer {
1118
1331
  const context = await this.buildContext(req, pageType);
1119
1332
  const html = await renderWithLayout(this.liquid, `templates/${templateName}`, context, this.themePath);
1120
1333
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
1334
+ res.setHeader('Cache-Control', 'no-cache');
1121
1335
  res.send(html);
1122
1336
  } catch (error) {
1123
1337
  next(error);
1124
1338
  }
1125
1339
  });
1126
1340
 
1341
+ // Catch-all route for category/collection handles at root level (e.g., /toys-and-games)
1342
+ // This handles links from widgets that use category handles directly
1343
+ this.app.get('/:handle', async (req, res, next) => {
1344
+ try {
1345
+ const handle = req.params.handle;
1346
+
1347
+ // Skip if it's a known route path or static file extension
1348
+ const knownPaths = ['assets', 'images', 'api', 'dev', 'favicon.ico', 'products', 'collections', 'categories', 'brands', 'cart', 'search', 'page', 'pages'];
1349
+ const staticExtensions = ['.css', '.js', '.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp', '.woff', '.woff2', '.ttf', '.eot', '.ico'];
1350
+ const hasStaticExtension = staticExtensions.some(ext => handle.toLowerCase().endsWith(ext));
1351
+
1352
+ if (knownPaths.includes(handle) || hasStaticExtension) {
1353
+ return next();
1354
+ }
1355
+
1356
+ // Build context to get categories and products
1357
+ const context = await this.buildContext(req, 'collection', { collectionHandle: handle });
1358
+
1359
+ // Try to find category by handle (case-insensitive)
1360
+ const category = context.categories?.find(c =>
1361
+ (c.handle && c.handle.toLowerCase() === handle.toLowerCase()) ||
1362
+ (c.name && c.name.toLowerCase().replace(/\s+/g, '-') === handle.toLowerCase())
1363
+ );
1364
+
1365
+ if (category) {
1366
+ // Found a category - render collection page
1367
+ context.category = category;
1368
+ context.collection = category;
1369
+
1370
+ // Filter products by this category
1371
+ const filteredProducts = (context.products || []).filter(p =>
1372
+ p.categoryId === category.id ||
1373
+ String(p.categoryId).toLowerCase() === String(category.id).toLowerCase()
1374
+ );
1375
+
1376
+ // Ensure all products have proper URL field if missing
1377
+ filteredProducts.forEach(product => {
1378
+ if (!product.url && !product.link) {
1379
+ const productHandle = product.handle || product.slug || product.id;
1380
+ product.url = `/products/${productHandle}`;
1381
+ product.link = `/products/${productHandle}`;
1382
+ }
1383
+ });
1384
+
1385
+ context.collection.products = filteredProducts;
1386
+ context.collection.totalProducts = filteredProducts.length;
1387
+ context.products = filteredProducts; // Also set in products array for consistency
1388
+
1389
+ console.log(`[ROOT COLLECTION] Category: ${category.name}, Products: ${filteredProducts.length}`);
1390
+
1391
+ // Try to render collection template
1392
+ let html;
1393
+ const templateOptions = ['templates/categories', 'templates/category', 'templates/collection', 'templates/products'];
1394
+ for (const template of templateOptions) {
1395
+ try {
1396
+ html = await renderWithLayout(this.liquid, template, context, this.themePath);
1397
+ break;
1398
+ } catch (error) {
1399
+ if (!error.message?.includes('Template not found')) {
1400
+ throw error;
1401
+ }
1402
+ }
1403
+ }
1404
+
1405
+ if (html) {
1406
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
1407
+ res.setHeader('Cache-Control', 'no-cache');
1408
+ return res.send(html);
1409
+ }
1410
+ }
1411
+
1412
+ // Not a category - check if it's a product handle
1413
+ const product = context.products?.find(p =>
1414
+ (p.handle && p.handle.toLowerCase() === handle.toLowerCase()) ||
1415
+ (p.slug && p.slug.toLowerCase() === handle.toLowerCase())
1416
+ );
1417
+
1418
+ if (product) {
1419
+ // Found a product - render product detail page
1420
+ const productContext = await this.buildContext(req, 'product', { productHandle: product.handle || product.slug || handle });
1421
+
1422
+ let html;
1423
+ const templateOptions = ['templates/product-detail', 'templates/product', 'templates/product-page'];
1424
+ for (const template of templateOptions) {
1425
+ try {
1426
+ html = await renderWithLayout(this.liquid, template, productContext, this.themePath);
1427
+ break;
1428
+ } catch (error) {
1429
+ if (!error.message?.includes('Template not found')) {
1430
+ throw error;
1431
+ }
1432
+ }
1433
+ }
1434
+
1435
+ if (html) {
1436
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
1437
+ res.setHeader('Cache-Control', 'no-cache');
1438
+ return res.send(html);
1439
+ }
1440
+ }
1441
+
1442
+ // Not found - pass to next middleware (will eventually 404)
1443
+ next();
1444
+ } catch (error) {
1445
+ // If there's an error, pass to next middleware
1446
+ next(error);
1447
+ }
1448
+ });
1449
+
1127
1450
  // Favicon handler
1128
1451
  this.app.get('/favicon.ico', (req, res) => {
1129
1452
  res.status(204).end(); // No Content
@@ -1267,10 +1590,13 @@ class DevServer {
1267
1590
 
1268
1591
  try {
1269
1592
  // Load settings from settings_data.json
1593
+ // Extract 'current' section to match webstore behavior
1270
1594
  const settingsPath = path.join(this.themePath, 'config', 'settings_data.json');
1271
1595
  if (fs.existsSync(settingsPath)) {
1272
1596
  const settingsData = fs.readFileSync(settingsPath, 'utf8');
1273
- context.settings = JSON.parse(settingsData);
1597
+ const parsedData = JSON.parse(settingsData);
1598
+ // Extract 'current' section like loadThemeSettings() does
1599
+ context.settings = parsedData.current || parsedData.presets?.default || {};
1274
1600
  }
1275
1601
  } catch (error) {
1276
1602
  console.warn('[CONTEXT] Failed to load settings:', error.message);
@@ -1350,6 +1676,50 @@ class DevServer {
1350
1676
  // Load products
1351
1677
  const productsResponse = await this.apiClient.getProducts({ limit: 50, offset: 0 });
1352
1678
  context.products = productsResponse.products || productsResponse.data?.products || [];
1679
+
1680
+ // Ensure all products have proper URL, images, and stock fields
1681
+ context.products = context.products.map((product, index) => {
1682
+ // Ensure URL field exists
1683
+ if (!product.url && !product.link) {
1684
+ const handle = product.handle || product.slug || product.id;
1685
+ product.url = `/products/${handle}`;
1686
+ product.link = `/products/${handle}`;
1687
+
1688
+ // Debug first few products to verify URL generation
1689
+ if (index < 3) {
1690
+ console.log(`[CONTEXT] Product ${index + 1} URL: ${product.url} (handle: ${product.handle || 'none'}, slug: ${product.slug || 'none'}, id: ${product.id || 'none'})`);
1691
+ }
1692
+ }
1693
+ // Ensure stock fields exist (they should from mock-data, but double-check)
1694
+ if (product.stock === undefined && product.stockQuantity === undefined) {
1695
+ // Calculate from variants if available
1696
+ if (product.variants && product.variants.length > 0) {
1697
+ const totalStock = product.variants.reduce((sum, v) => sum + (v.stock || 0), 0);
1698
+ product.stock = totalStock;
1699
+ product.stockQuantity = totalStock;
1700
+ product.inStock = totalStock > 0;
1701
+ product.available = totalStock > 0;
1702
+ } else {
1703
+ // Default to in stock if no stock info
1704
+ product.stock = 10;
1705
+ product.stockQuantity = 10;
1706
+ product.inStock = true;
1707
+ product.available = true;
1708
+ }
1709
+ }
1710
+ // Ensure image fields exist (they should from mock-data, but double-check)
1711
+ if (!product.thumbnailImage1 && !product.imageUrl && (!product.images || product.images.length === 0)) {
1712
+ // Fallback to picsum placeholder
1713
+ const imageId = product.id ? String(product.id).replace(/\D/g, '') || '1' : '1';
1714
+ product.imageUrl = `https://picsum.photos/seed/${imageId}/800/800`;
1715
+ product.thumbnailImage1 = {
1716
+ url: product.imageUrl,
1717
+ altText: product.title || product.name || 'Product image'
1718
+ };
1719
+ }
1720
+ return product;
1721
+ });
1722
+
1353
1723
  console.log(`[CONTEXT] Loaded ${context.products.length} products`);
1354
1724
 
1355
1725
  // DEBUG: Verify products have quantity/stock field
@@ -1446,40 +1816,57 @@ class DevServer {
1446
1816
  if (pageType === 'product' && extra.productHandle) {
1447
1817
  // Load single product data
1448
1818
  try {
1819
+ const searchHandle = extra.productHandle.toLowerCase().trim();
1820
+ console.log(`[CONTEXT] Searching for product with handle: ${searchHandle}`);
1821
+
1449
1822
  // First try to find in already loaded products
1450
- let product = context.products?.find(p =>
1451
- (p.handle && p.handle.toLowerCase() === extra.productHandle.toLowerCase()) ||
1452
- (p.slug && p.slug.toLowerCase() === extra.productHandle.toLowerCase()) ||
1453
- (p.id && String(p.id).toLowerCase() === extra.productHandle.toLowerCase())
1454
- );
1823
+ // Check multiple fields: handle, slug, id, and also check if handle contains the ID
1824
+ let product = context.products?.find(p => {
1825
+ const pHandle = (p.handle || '').toLowerCase().trim();
1826
+ const pSlug = (p.slug || '').toLowerCase().trim();
1827
+ const pId = String(p.id || '').toLowerCase().trim();
1828
+ const pIdStr = String(p.id || '');
1829
+
1830
+ return pHandle === searchHandle ||
1831
+ pSlug === searchHandle ||
1832
+ pId === searchHandle ||
1833
+ (searchHandle.startsWith('product-') && pId === searchHandle.replace('product-', '')) ||
1834
+ (pHandle && searchHandle.includes(pIdStr)) ||
1835
+ (pSlug && searchHandle.includes(pIdStr));
1836
+ });
1455
1837
 
1456
1838
  // If not found, try loading more products
1457
1839
  if (!product) {
1840
+ console.log(`[CONTEXT] Product not found in initial list, loading more products...`);
1458
1841
  const productsResponse = await this.apiClient.getProducts({ limit: 100 });
1459
1842
  const allProducts = productsResponse.products || productsResponse.data?.products || [];
1460
- product = allProducts.find(p =>
1461
- (p.handle && p.handle.toLowerCase() === extra.productHandle.toLowerCase()) ||
1462
- (p.slug && p.slug.toLowerCase() === extra.productHandle.toLowerCase()) ||
1463
- (p.id && String(p.id).toLowerCase() === extra.productHandle.toLowerCase())
1464
- );
1843
+ product = allProducts.find(p => {
1844
+ const pHandle = (p.handle || '').toLowerCase().trim();
1845
+ const pSlug = (p.slug || '').toLowerCase().trim();
1846
+ const pId = String(p.id || '').toLowerCase().trim();
1847
+ const pIdStr = String(p.id || '');
1848
+
1849
+ return pHandle === searchHandle ||
1850
+ pSlug === searchHandle ||
1851
+ pId === searchHandle ||
1852
+ (searchHandle.startsWith('product-') && pId === searchHandle.replace('product-', '')) ||
1853
+ (pHandle && searchHandle.includes(pIdStr)) ||
1854
+ (pSlug && searchHandle.includes(pIdStr));
1855
+ });
1465
1856
  }
1466
1857
 
1467
1858
  if (product) {
1468
1859
  context.product = product;
1469
- console.log(`[CONTEXT] Found product: ${product.title || product.name} (handle: ${product.handle || product.slug})`);
1860
+ console.log(`[CONTEXT] Found product: ${product.title || product.name} (handle: ${product.handle || product.slug || product.id})`);
1470
1861
  } else {
1471
- console.warn(`[CONTEXT] Product not found with handle/slug/id: ${extra.productHandle}`);
1472
- // Fallback to first product if handle not found
1473
- if (context.products && context.products.length > 0) {
1474
- context.product = context.products[0];
1475
- console.log(`[CONTEXT] Using fallback product: ${context.product.title || context.product.name}`);
1476
- }
1862
+ console.warn(`[CONTEXT] ⚠️ Product not found with handle/slug/id: ${extra.productHandle}`);
1863
+ console.warn(`[CONTEXT] Available product handles: ${(context.products || []).slice(0, 5).map(p => p.handle || p.slug || p.id).join(', ')}`);
1864
+ // Don't use fallback - let the route handler deal with 404
1477
1865
  }
1478
1866
  } catch (error) {
1479
- console.warn('[CONTEXT] Failed to load product:', error.message);
1480
- if (context.products && context.products.length > 0) {
1481
- context.product = context.products[0];
1482
- }
1867
+ console.error('[CONTEXT] Failed to load product:', error.message);
1868
+ console.error('[CONTEXT] Error stack:', error.stack);
1869
+ // Don't use fallback on error - let route handle it
1483
1870
  }
1484
1871
  }
1485
1872
  } catch (error) {
@@ -2586,14 +2973,36 @@ class DevServer {
2586
2973
  */
2587
2974
  async stop() {
2588
2975
  return new Promise((resolve) => {
2976
+ let resolved = false;
2977
+ const safeResolve = () => {
2978
+ if (!resolved) {
2979
+ resolved = true;
2980
+ console.log(chalk.yellow('\n👋 Server stopped'));
2981
+ resolve();
2982
+ }
2983
+ };
2984
+
2985
+ // Increase max listeners to prevent warning if multiple shutdown attempts
2986
+ if (this.server && this.server.setMaxListeners) {
2987
+ this.server.setMaxListeners(20);
2988
+ }
2989
+
2589
2990
  // Stop file watcher
2590
2991
  if (this.fileWatcher && typeof this.fileWatcher.close === 'function') {
2591
- this.fileWatcher.close();
2992
+ try {
2993
+ this.fileWatcher.close();
2994
+ } catch (error) {
2995
+ // Ignore errors when closing watcher
2996
+ }
2592
2997
  }
2593
2998
 
2594
2999
  // Close Socket.IO
2595
3000
  if (this.io && typeof this.io.close === 'function') {
2596
- this.io.close();
3001
+ try {
3002
+ this.io.close();
3003
+ } catch (error) {
3004
+ // Ignore errors when closing Socket.IO
3005
+ }
2597
3006
  }
2598
3007
 
2599
3008
  // Stop mock API
@@ -2601,14 +3010,37 @@ class DevServer {
2601
3010
  this.mockApi.stop().catch(() => {});
2602
3011
  }
2603
3012
 
2604
- // Close HTTP server
3013
+ // Close HTTP server - check if it's actually listening/running
2605
3014
  if (this.server) {
2606
- this.server.close(() => {
2607
- console.log(chalk.yellow('\n👋 Server stopped'));
2608
- resolve();
3015
+ // Check if server is listening before trying to close
3016
+ const isListening = this.server.listening || false;
3017
+
3018
+ if (!isListening) {
3019
+ // Server is already closed/not running
3020
+ safeResolve();
3021
+ return;
3022
+ }
3023
+
3024
+ // Remove all existing listeners to prevent memory leak warning
3025
+ this.server.removeAllListeners('close');
3026
+
3027
+ // Close the server - use callback instead of event listener to avoid duplicate calls
3028
+ this.server.close((err) => {
3029
+ if (err) {
3030
+ // Ignore ERR_SERVER_NOT_RUNNING errors as they're harmless
3031
+ if (err.code !== 'ERR_SERVER_NOT_RUNNING') {
3032
+ console.error(chalk.red('Error closing server:'), err.message);
3033
+ }
3034
+ }
3035
+ safeResolve();
2609
3036
  });
3037
+
3038
+ // Fallback timeout in case close callback doesn't fire
3039
+ setTimeout(() => {
3040
+ safeResolve();
3041
+ }, 2000);
2610
3042
  } else {
2611
- resolve();
3043
+ safeResolve();
2612
3044
  }
2613
3045
  });
2614
3046
  }
@@ -65,6 +65,25 @@ function generateMockProducts(count = 20) {
65
65
  console.log(`[MOCK DATA] Generating ${count} mock products with variations and stock...`);
66
66
  const products = [];
67
67
 
68
+ // Category mapping - map category names to category IDs (matching generateMockCategories order)
69
+ const categoryMap = {
70
+ 'Electronics': 'category-1',
71
+ 'Clothing': 'category-2',
72
+ 'Accessories': 'category-3',
73
+ 'Home & Garden': 'category-4',
74
+ 'Home': 'category-4', // Alias for Home & Garden
75
+ 'Sports & Fitness': 'category-5',
76
+ 'Sports': 'category-5', // Alias for Sports & Fitness
77
+ 'Books & Media': 'category-6',
78
+ 'Toys & Games': 'category-7',
79
+ 'Beauty & Health': 'category-8',
80
+ 'Automotive': 'category-9',
81
+ 'Food & Beverages': 'category-10',
82
+ 'Furniture': 'category-4', // Map Furniture to Home & Garden
83
+ 'Office': 'category-6', // Map Office to Books & Media (or could be separate)
84
+ 'Footwear': 'category-2' // Map Footwear to Clothing
85
+ };
86
+
68
87
  // Product data with images from picsum.photos (reliable placeholder service)
69
88
  const productData = [
70
89
  { name: 'Wireless Headphones', category: 'Electronics', imageId: 1 },
@@ -115,7 +134,13 @@ function generateMockProducts(count = 20) {
115
134
  const productId = `product-${i + 1}`;
116
135
  const basePrice = Math.floor(Math.random() * 50000) + 1000; // $10 to $500 (in cents)
117
136
  const comparePrice = basePrice * 1.5;
118
- const productName = productNames[i % productNames.length];
137
+ const productTemplate = productData[i % productData.length];
138
+ const productName = productTemplate.name;
139
+ const productCategory = productTemplate.category;
140
+ const productImageId = productTemplate.imageId;
141
+
142
+ // Get categoryId from category name using categoryMap
143
+ const categoryId = categoryMap[productCategory] || 'category-1'; // Default to Electronics if not found
119
144
 
120
145
  // Determine stock quantity - mix of low stock, high stock, and out of stock
121
146
  const stockType = i % 5;
@@ -278,18 +303,19 @@ function generateMockProducts(count = 20) {
278
303
  stock: totalStock,
279
304
  stockQuantity: totalStock, // Alternative property for stock
280
305
  sku: `SKU-${i + 1}`,
281
- categoryId: `category-${(i % 10) + 1}`,
306
+ categoryId: categoryId, // Use mapped categoryId based on product category
307
+ category: productCategory, // Also include category name for reference
282
308
  brandId: `brand-${(i % 8) + 1}`,
283
309
  // Primary thumbnail for product cards
284
310
  thumbnailImage1: {
285
- url: `https://picsum.photos/seed/${productData[i % productData.length].imageId}/800/800`,
311
+ url: `https://picsum.photos/seed/${productImageId}/800/800`,
286
312
  altText: productName
287
313
  },
288
- imageUrl: `https://picsum.photos/seed/${productData[i % productData.length].imageId}/800/800`,
314
+ imageUrl: `https://picsum.photos/seed/${productImageId}/800/800`,
289
315
  images: [
290
316
  {
291
317
  id: `img-${i + 1}-1`,
292
- url: `https://picsum.photos/seed/${productData[i % productData.length].imageId}/800/800`,
318
+ url: `https://picsum.photos/seed/${productImageId}/800/800`,
293
319
  alt: productName,
294
320
  altText: productName,
295
321
  width: 800,
@@ -297,7 +323,7 @@ function generateMockProducts(count = 20) {
297
323
  },
298
324
  {
299
325
  id: `img-${i + 1}-2`,
300
- url: `https://picsum.photos/seed/${productData[i % productData.length].imageId + 100}/800/800`,
326
+ url: `https://picsum.photos/seed/${productImageId + 100}/800/800`,
301
327
  alt: `${productName} - View 2`,
302
328
  altText: `${productName} - View 2`,
303
329
  width: 800,
@@ -305,7 +331,7 @@ function generateMockProducts(count = 20) {
305
331
  },
306
332
  {
307
333
  id: `img-${i + 1}-3`,
308
- url: `https://picsum.photos/seed/${productData[i % productData.length].imageId + 200}/800/800`,
334
+ url: `https://picsum.photos/seed/${productImageId + 200}/800/800`,
309
335
  alt: `${productName} - View 3`,
310
336
  altText: `${productName} - View 3`,
311
337
  width: 800,
@@ -342,10 +368,22 @@ function generateMockProducts(count = 20) {
342
368
  const productsWithVariations = products.filter(p => (p.variants?.length || 0) > 1).length;
343
369
  const productsWithOptions = products.filter(p => (p.options?.length || 0) > 0).length;
344
370
  const outOfStock = products.filter(p => !p.inStock || (p.stock || 0) === 0).length;
371
+
372
+ // Log category distribution
373
+ const categoryDistribution = {};
374
+ products.forEach(p => {
375
+ const cat = p.categoryId || 'unknown';
376
+ categoryDistribution[cat] = (categoryDistribution[cat] || 0) + 1;
377
+ });
378
+ const categorySummary = Object.entries(categoryDistribution)
379
+ .map(([cat, count]) => `${cat}: ${count}`)
380
+ .join(', ');
381
+
345
382
  console.log(`[MOCK DATA] ✅ Generated ${products.length} products:`);
346
383
  console.log(` - With variations: ${productsWithVariations}`);
347
384
  console.log(` - With options: ${productsWithOptions}`);
348
385
  console.log(` - Out of stock: ${outOfStock}`);
386
+ console.log(` - Category distribution: ${categorySummary}`);
349
387
 
350
388
  return products;
351
389
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o2vend/theme-cli",
3
- "version": "1.0.32",
3
+ "version": "1.0.33",
4
4
  "description": "O2VEND Theme Development CLI - Standalone tool for local theme development",
5
5
  "bin": {
6
6
  "o2vend": "./bin/o2vend"