@o2vend/theme-cli 1.0.32

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 (116) hide show
  1. package/README.md +425 -0
  2. package/assets/Logo_o2vend.png +0 -0
  3. package/assets/favicon.png +0 -0
  4. package/assets/logo-white.png +0 -0
  5. package/bin/o2vend +42 -0
  6. package/config/widget-map.json +50 -0
  7. package/lib/commands/check.js +201 -0
  8. package/lib/commands/generate.js +33 -0
  9. package/lib/commands/init.js +214 -0
  10. package/lib/commands/optimize.js +216 -0
  11. package/lib/commands/package.js +208 -0
  12. package/lib/commands/serve.js +105 -0
  13. package/lib/commands/validate.js +191 -0
  14. package/lib/lib/api-client.js +357 -0
  15. package/lib/lib/dev-server.js +2618 -0
  16. package/lib/lib/file-watcher.js +80 -0
  17. package/lib/lib/hot-reload.js +106 -0
  18. package/lib/lib/liquid-engine.js +822 -0
  19. package/lib/lib/liquid-filters.js +671 -0
  20. package/lib/lib/mock-api-server.js +989 -0
  21. package/lib/lib/mock-data.js +1468 -0
  22. package/lib/lib/widget-service.js +321 -0
  23. package/package.json +70 -0
  24. package/test-theme/README.md +27 -0
  25. package/test-theme/assets/async-sections.js +446 -0
  26. package/test-theme/assets/cart-drawer.js +463 -0
  27. package/test-theme/assets/cart-manager.js +223 -0
  28. package/test-theme/assets/checkout-price-handler.js +368 -0
  29. package/test-theme/assets/components.css +4629 -0
  30. package/test-theme/assets/delivery-zone.css +299 -0
  31. package/test-theme/assets/delivery-zone.js +396 -0
  32. package/test-theme/assets/logo.png +0 -0
  33. package/test-theme/assets/sections.css +48 -0
  34. package/test-theme/assets/theme.css +3500 -0
  35. package/test-theme/assets/theme.js +3745 -0
  36. package/test-theme/config/settings_data.json +292 -0
  37. package/test-theme/config/settings_schema.json +1050 -0
  38. package/test-theme/layout/theme.liquid +195 -0
  39. package/test-theme/locales/en.default.json +260 -0
  40. package/test-theme/sections/content-fallback.liquid +53 -0
  41. package/test-theme/sections/content.liquid +57 -0
  42. package/test-theme/sections/footer-fallback.liquid +328 -0
  43. package/test-theme/sections/footer.liquid +278 -0
  44. package/test-theme/sections/header-fallback.liquid +1805 -0
  45. package/test-theme/sections/header.liquid +1145 -0
  46. package/test-theme/sections/hero-fallback.liquid +212 -0
  47. package/test-theme/sections/hero.liquid +136 -0
  48. package/test-theme/snippets/account-sidebar.liquid +200 -0
  49. package/test-theme/snippets/add-to-cart-modal.liquid +484 -0
  50. package/test-theme/snippets/breadcrumbs.liquid +134 -0
  51. package/test-theme/snippets/cart-drawer.liquid +467 -0
  52. package/test-theme/snippets/delivery-zone-city-selector.liquid +79 -0
  53. package/test-theme/snippets/delivery-zone-modal.liquid +337 -0
  54. package/test-theme/snippets/delivery-zone-search.liquid +78 -0
  55. package/test-theme/snippets/icon.liquid +105 -0
  56. package/test-theme/snippets/login-modal.liquid +346 -0
  57. package/test-theme/snippets/mega-menu.liquid +812 -0
  58. package/test-theme/snippets/news-thumbnail.liquid +187 -0
  59. package/test-theme/snippets/pagination.liquid +120 -0
  60. package/test-theme/snippets/price.liquid +92 -0
  61. package/test-theme/snippets/product-card-related.liquid +78 -0
  62. package/test-theme/snippets/product-card-simple.liquid +41 -0
  63. package/test-theme/snippets/product-card.liquid +697 -0
  64. package/test-theme/snippets/rating.liquid +85 -0
  65. package/test-theme/snippets/skeleton-collection-grid.liquid +114 -0
  66. package/test-theme/snippets/skeleton-product-card.liquid +124 -0
  67. package/test-theme/snippets/skeleton-product-grid.liquid +34 -0
  68. package/test-theme/snippets/social-sharing.liquid +185 -0
  69. package/test-theme/templates/account/dashboard.liquid +401 -0
  70. package/test-theme/templates/account/loyalty-redemption.liquid +405 -0
  71. package/test-theme/templates/account/loyalty.liquid +588 -0
  72. package/test-theme/templates/account/order-detail.liquid +230 -0
  73. package/test-theme/templates/account/orders.liquid +349 -0
  74. package/test-theme/templates/account/profile.liquid +758 -0
  75. package/test-theme/templates/account/register.liquid +232 -0
  76. package/test-theme/templates/account/return-orders.liquid +348 -0
  77. package/test-theme/templates/account/store-credit.liquid +464 -0
  78. package/test-theme/templates/account/subscriptions.liquid +601 -0
  79. package/test-theme/templates/account/wishlist.liquid +419 -0
  80. package/test-theme/templates/address-book.liquid +1092 -0
  81. package/test-theme/templates/categories.liquid +452 -0
  82. package/test-theme/templates/checkout.liquid +4511 -0
  83. package/test-theme/templates/error.liquid +384 -0
  84. package/test-theme/templates/index.liquid +11 -0
  85. package/test-theme/templates/login.liquid +185 -0
  86. package/test-theme/templates/order-confirmation.liquid +720 -0
  87. package/test-theme/templates/page.liquid +297 -0
  88. package/test-theme/templates/product-detail.liquid +4363 -0
  89. package/test-theme/templates/products.liquid +518 -0
  90. package/test-theme/templates/search.liquid +922 -0
  91. package/test-theme/theme.json.example +19 -0
  92. package/test-theme/widgets/brand-carousel.liquid +676 -0
  93. package/test-theme/widgets/brand.liquid +245 -0
  94. package/test-theme/widgets/carousel.liquid +843 -0
  95. package/test-theme/widgets/category-list-carousel.liquid +656 -0
  96. package/test-theme/widgets/category-list.liquid +340 -0
  97. package/test-theme/widgets/category.liquid +475 -0
  98. package/test-theme/widgets/discount-time.liquid +176 -0
  99. package/test-theme/widgets/footer-menu.liquid +695 -0
  100. package/test-theme/widgets/footer.liquid +179 -0
  101. package/test-theme/widgets/gallery.liquid +271 -0
  102. package/test-theme/widgets/header-menu.liquid +932 -0
  103. package/test-theme/widgets/header.liquid +159 -0
  104. package/test-theme/widgets/html.liquid +214 -0
  105. package/test-theme/widgets/news.liquid +217 -0
  106. package/test-theme/widgets/product-canvas.liquid +235 -0
  107. package/test-theme/widgets/product-carousel.liquid +502 -0
  108. package/test-theme/widgets/product.liquid +45 -0
  109. package/test-theme/widgets/recently-viewed.liquid +26 -0
  110. package/test-theme/widgets/shared/product-grid.liquid +339 -0
  111. package/test-theme/widgets/simple-product.liquid +42 -0
  112. package/test-theme/widgets/single-product.liquid +610 -0
  113. package/test-theme/widgets/spacebar-carousel.liquid +663 -0
  114. package/test-theme/widgets/spacebar.liquid +279 -0
  115. package/test-theme/widgets/splash.liquid +378 -0
  116. package/test-theme/widgets/testimonial-carousel.liquid +709 -0
@@ -0,0 +1,822 @@
1
+ /**
2
+ * LiquidJS Engine Setup
3
+ * Configures LiquidJS with all O2VEND filters and custom tags
4
+ */
5
+
6
+ const { Liquid } = require('liquidjs');
7
+ const path = require('path');
8
+ const LiquidHelperService = require('./liquid-filters');
9
+ const fs = require('fs');
10
+ const chalk = require('chalk');
11
+
12
+ /**
13
+ * Create and configure LiquidJS engine
14
+ * @param {string} themePath - Path to theme directory
15
+ * @param {Object} options - Engine options
16
+ * @returns {Liquid} Configured LiquidJS engine
17
+ */
18
+ // Store the current rendering context globally so sections can access it
19
+ let currentRenderingContext = null;
20
+
21
+ // Track widgets currently being rendered to prevent infinite recursion
22
+ const renderingStack = new Set();
23
+ const MAX_RENDER_DEPTH = 50; // Maximum number of nested renders allowed
24
+ let currentRenderDepth = 0;
25
+
26
+ function createLiquidEngine(themePath, options = {}) {
27
+ const {
28
+ cache = false, // Disable cache in dev mode
29
+ extname = '.liquid'
30
+ } = options;
31
+
32
+ // Create Liquid engine
33
+ // NOTE: Do NOT use outputEscape: 'escape' as it breaks JSON output in script tags
34
+ // Templates should use explicit escaping when needed (e.g., {{ variable | escape }})
35
+ const liquid = new Liquid({
36
+ root: themePath,
37
+ extname: extname,
38
+ cache: cache,
39
+ strictFilters: false,
40
+ strictVariables: false,
41
+ ownPropertyOnly: false,
42
+ // Enable dynamic properties to access array methods
43
+ dynamicPartials: true,
44
+ // Ensure arrays are properly handled
45
+ jsTruthy: true,
46
+ // Enable async filters
47
+ async: true
48
+ });
49
+
50
+ // Register all filters from LiquidHelperService
51
+ const liquidHelperService = new LiquidHelperService();
52
+ const filters = liquidHelperService.getFilters();
53
+
54
+ Object.keys(filters).forEach(filterName => {
55
+ liquid.registerFilter(filterName, filters[filterName]);
56
+ });
57
+
58
+ // Register custom filters for JSON output compatibility
59
+ // These help handle edge cases where templates output JSON directly
60
+
61
+ // json_value: Outputs a JSON-safe value (handles null, undefined, objects)
62
+ liquid.registerFilter('json_value', (value) => {
63
+ if (value === null || value === undefined) {
64
+ return 'null';
65
+ }
66
+ if (typeof value === 'object') {
67
+ return JSON.stringify(value);
68
+ }
69
+ if (typeof value === 'string') {
70
+ return JSON.stringify(value);
71
+ }
72
+ return String(value);
73
+ });
74
+
75
+ // safe_json: Ensures proper JSON encoding without double-encoding strings
76
+ liquid.registerFilter('safe_json', (value) => {
77
+ if (value === null || value === undefined) {
78
+ return 'null';
79
+ }
80
+ // If it's already a JSON string, don't double-encode
81
+ if (typeof value === 'string') {
82
+ try {
83
+ JSON.parse(value);
84
+ return value; // Already valid JSON
85
+ } catch {
86
+ return JSON.stringify(value); // Encode the string
87
+ }
88
+ }
89
+ return JSON.stringify(value);
90
+ });
91
+
92
+ // Register custom tags
93
+ registerCustomTags(liquid, themePath);
94
+
95
+ return liquid;
96
+ }
97
+
98
+ /**
99
+ * Register custom Liquid tags (section, hook, schema)
100
+ * @param {Liquid} liquid - LiquidJS engine instance
101
+ * @param {string} themePath - Theme path
102
+ */
103
+ function registerCustomTags(liquid, themePath) {
104
+ // Register section tag
105
+ liquid.registerTag('section', {
106
+ parse: function(tagToken, remainTokens) {
107
+ this.sectionPath = tagToken.args.trim().replace(/^['"]|['"]$/g, '');
108
+ },
109
+ render: async function(scope, hash) {
110
+ let sectionPath = this.sectionPath;
111
+ if (!sectionPath) {
112
+ console.error('[SECTION] No section path provided');
113
+ return '';
114
+ }
115
+
116
+ try {
117
+ // Normalize section path - remove 'sections/' prefix if present
118
+ // Handle both 'hero' and 'sections/hero' formats
119
+ sectionPath = sectionPath.replace(/^sections\//, '').replace(/^sections\\/, '');
120
+
121
+ const sectionFile = path.join(themePath, 'sections', `${sectionPath}.liquid`);
122
+ if (fs.existsSync(sectionFile)) {
123
+ let sectionContent = fs.readFileSync(sectionFile, 'utf8');
124
+
125
+ // Pre-process section to convert render_widget filter to tag
126
+ const originalContent = sectionContent;
127
+ try {
128
+ sectionContent = preprocessTemplate(sectionContent);
129
+ } catch (error) {
130
+ console.error(chalk.red(`[SECTION] ❌ Error preprocessing ${sectionPath}: ${error.message}`));
131
+ sectionContent = originalContent;
132
+ }
133
+
134
+ const scopeContexts = Array.isArray(scope?.contexts) ? scope.contexts : [];
135
+ const primaryScope = scopeContexts.length > 0
136
+ ? scopeContexts[0]
137
+ : (scope?.environments || scope?.context || {});
138
+
139
+ // Merge with current rendering context to ensure all data is available
140
+ const fullContext = currentRenderingContext
141
+ ? { ...currentRenderingContext, ...primaryScope }
142
+ : primaryScope;
143
+
144
+ const context = {
145
+ ...fullContext,
146
+ section: fullContext.section || {}
147
+ };
148
+
149
+ // Render section
150
+ try {
151
+ const rendered = await liquid.parseAndRender(sectionContent, context);
152
+ return rendered;
153
+ } catch (renderError) {
154
+ console.error(chalk.red(`[SECTION] ❌ Error rendering ${sectionPath}: ${renderError.message}`));
155
+ throw renderError;
156
+ }
157
+ } else {
158
+ console.warn(`[SECTION] Section file not found: ${sectionFile}`);
159
+ return '';
160
+ }
161
+ } catch (error) {
162
+ console.error(`[SECTION] Error rendering section ${sectionPath}:`, error.message);
163
+ return '';
164
+ }
165
+ }
166
+ });
167
+
168
+ // Register hook tag (simplified for CLI - plugins not supported)
169
+ liquid.registerTag('hook', {
170
+ parse: function(tagToken, remainTokens) {
171
+ const args = tagToken.args;
172
+ if (typeof args === 'string') {
173
+ this.hookName = args.trim().replace(/^['"]|['"]$/g, '');
174
+ } else {
175
+ this.hookName = String(args || '').trim().replace(/^['"]|['"]$/g, '');
176
+ }
177
+ },
178
+ render: async function(scope, hash) {
179
+ // In CLI, hooks are not supported (no plugin system)
180
+ // Return empty string
181
+ return '';
182
+ }
183
+ });
184
+
185
+ // Register schema tag as a block tag
186
+ // Schema blocks contain JSON configuration and should not be rendered
187
+ liquid.registerTag('schema', {
188
+ parse: function(tagToken, remainTokens) {
189
+ // Consume all tokens until endschema
190
+ this.tokens = [];
191
+ let token;
192
+ while ((token = remainTokens.shift())) {
193
+ if (token.name === 'endschema') {
194
+ break;
195
+ }
196
+ this.tokens.push(token);
197
+ }
198
+ },
199
+ render: async function(scope, hash) {
200
+ // Schema blocks are for configuration only - don't render anything
201
+ return '';
202
+ }
203
+ });
204
+
205
+ // Register endschema tag (closing tag for schema)
206
+ liquid.registerTag('endschema', {
207
+ parse: function(tagToken, remainTokens) {
208
+ // No-op - just marks the end of schema block
209
+ },
210
+ render: async function(scope, hash) {
211
+ return '';
212
+ }
213
+ });
214
+
215
+ // Register render_widget tag as an alternative to the filter
216
+ // This allows templates to use: {% render_widget widget %}
217
+ liquid.registerTag('render_widget', {
218
+ parse: function(tagToken, remainTokens) {
219
+ const args = tagToken.args.trim();
220
+ this.widgetVar = args || 'widget';
221
+ },
222
+ render: async function(scope, hash) {
223
+ try {
224
+ // Get widget from scope using scope.get() for proper variable resolution
225
+ let widget = await scope.get(this.widgetVar.split('.'));
226
+
227
+ if (!widget || !widget.type) {
228
+ return '';
229
+ }
230
+
231
+ // Render widget template
232
+ if (widget.template_path) {
233
+ const widgetPath = path.join(themePath, `${widget.template_path}.liquid`);
234
+ const altPath = path.join(themePath, widget.template_path);
235
+
236
+ const finalPath = fs.existsSync(widgetPath) ? widgetPath :
237
+ (fs.existsSync(altPath) ? altPath : null);
238
+
239
+ if (finalPath) {
240
+ const fullContext = currentRenderingContext
241
+ ? { ...currentRenderingContext, widget: widget }
242
+ : { widget: widget };
243
+ const widgetContent = fs.readFileSync(finalPath, 'utf8');
244
+ return await liquid.parseAndRender(widgetContent, fullContext);
245
+ }
246
+ return `<div class="widget-error">Widget template not found: ${widget.template_path}</div>`;
247
+ }
248
+ return `<div class="widget-error">Widget missing template_path</div>`;
249
+ } catch (error) {
250
+ console.error(chalk.red(`[RENDER_WIDGET] Error: ${error.message}`));
251
+ return `<div class="widget-error">Error: ${error.message}</div>`;
252
+ }
253
+ }
254
+ });
255
+
256
+ // Override include/render tags to handle widget paths
257
+ // This allows sections to use {% include 'widgets/banner' %} or {% render 'widgets/banner' %}
258
+ const originalRenderFile = liquid.renderFile.bind(liquid);
259
+
260
+ // Override renderFile to handle widget paths
261
+ liquid.renderFile = async function(file, ctx, opts) {
262
+ // If the file path starts with 'widgets/', resolve it correctly
263
+ if (typeof file === 'string' && file.startsWith('widgets/')) {
264
+ const widgetName = file.replace(/^widgets\//, '').replace(/\.liquid$/, '');
265
+ const widgetPath = path.join(themePath, 'widgets', `${widgetName}.liquid`);
266
+ if (fs.existsSync(widgetPath)) {
267
+ return originalRenderFile(widgetPath, ctx, opts);
268
+ }
269
+ }
270
+ // Also handle snippets
271
+ if (typeof file === 'string' && file.startsWith('snippets/')) {
272
+ const snippetName = file.replace(/^snippets\//, '').replace(/\.liquid$/, '');
273
+ const snippetPath = path.join(themePath, 'snippets', `${snippetName}.liquid`);
274
+ if (fs.existsSync(snippetPath)) {
275
+ return originalRenderFile(snippetPath, ctx, opts);
276
+ }
277
+ }
278
+ // Default behavior
279
+ return originalRenderFile(file, ctx, opts);
280
+ };
281
+
282
+ // Override the include tag to handle widget and snippet paths
283
+ liquid.registerTag('include', {
284
+ parse: function(tagToken, remainTokens) {
285
+ // Parse arguments - handle both 'snippets/pagination' and 'snippets/pagination', pagination formats
286
+ const args = tagToken.args.trim();
287
+ // Extract file path (first argument before comma if present)
288
+ const fileMatch = args.match(/^['"]([^'"]+)['"]/);
289
+ this.file = fileMatch ? fileMatch[1] : args.split(',')[0].trim().replace(/^['"]|['"]$/g, '');
290
+ },
291
+ render: async function(scope, hash) {
292
+ let filePath = this.file;
293
+ if (!filePath) {
294
+ console.warn('[INCLUDE] No file path provided');
295
+ return '';
296
+ }
297
+
298
+ // Clean up file path
299
+ filePath = filePath.trim().replace(/^['"]|['"]$/g, '');
300
+
301
+ // Get full context for snippets/widgets
302
+ const scopeContexts = Array.isArray(scope?.contexts) ? scope.contexts : [];
303
+ const primaryScope = scopeContexts.length > 0
304
+ ? scopeContexts[0]
305
+ : (scope?.environments || scope?.context || {});
306
+
307
+ // Merge with current rendering context to ensure all data is available
308
+ const fullContext = currentRenderingContext
309
+ ? { ...currentRenderingContext, ...primaryScope }
310
+ : primaryScope;
311
+
312
+ // Handle widget paths
313
+ if (filePath.startsWith('widgets/')) {
314
+ const widgetName = filePath.replace(/^widgets\//, '').replace(/\.liquid$/, '');
315
+ const widgetPath = path.join(themePath, 'widgets', `${widgetName}.liquid`);
316
+ if (fs.existsSync(widgetPath)) {
317
+ return await liquid.parseAndRender(fs.readFileSync(widgetPath, 'utf8'), fullContext);
318
+ }
319
+ }
320
+
321
+ // Handle snippet paths
322
+ if (filePath.startsWith('snippets/')) {
323
+ const snippetName = filePath.replace(/^snippets\//, '').replace(/\.liquid$/, '');
324
+ const snippetPath = path.join(themePath, 'snippets', `${snippetName}.liquid`);
325
+ if (fs.existsSync(snippetPath)) {
326
+ return await liquid.parseAndRender(fs.readFileSync(snippetPath, 'utf8'), fullContext);
327
+ } else {
328
+ console.warn(`[INCLUDE] Snippet not found: ${snippetPath}`);
329
+ }
330
+ }
331
+
332
+ // Try to resolve file in snippets directory (default location)
333
+ const snippetPath = path.join(themePath, 'snippets', `${filePath}.liquid`);
334
+ if (fs.existsSync(snippetPath)) {
335
+ return await liquid.parseAndRender(fs.readFileSync(snippetPath, 'utf8'), fullContext);
336
+ }
337
+
338
+ // Try direct path
339
+ const resolvedPath = path.join(themePath, filePath);
340
+ if (fs.existsSync(resolvedPath)) {
341
+ return await liquid.parseAndRender(fs.readFileSync(resolvedPath, 'utf8'), fullContext);
342
+ }
343
+
344
+ // Try with .liquid extension
345
+ const resolvedPathWithExt = `${resolvedPath}.liquid`;
346
+ if (fs.existsSync(resolvedPathWithExt)) {
347
+ return await liquid.parseAndRender(fs.readFileSync(resolvedPathWithExt, 'utf8'), fullContext);
348
+ }
349
+
350
+ console.warn(`[INCLUDE] File not found: ${filePath} (tried: ${snippetPath}, ${resolvedPath}, ${resolvedPathWithExt})`);
351
+ return '';
352
+ }
353
+ });
354
+
355
+ // Override the render tag (similar to include but with isolated scope)
356
+ liquid.registerTag('render', {
357
+ parse: function(tagToken, remainTokens) {
358
+ // Parse the tag arguments
359
+ // Format: {% render 'path' %} or {% render variable.path, param: value %}
360
+ const args = tagToken.args.trim();
361
+
362
+ // Split by comma to separate file path from hash parameters
363
+ const commaIndex = args.indexOf(',');
364
+ if (commaIndex > 0) {
365
+ this.fileArg = args.substring(0, commaIndex).trim();
366
+ this.hashArgs = args.substring(commaIndex + 1).trim();
367
+ } else {
368
+ this.fileArg = args;
369
+ this.hashArgs = '';
370
+ }
371
+ },
372
+ render: async function(scope, hash) {
373
+ // RECURSION GUARD: Check render depth to prevent infinite loops
374
+ currentRenderDepth++;
375
+ if (currentRenderDepth > MAX_RENDER_DEPTH) {
376
+ console.error(chalk.red(`[RENDER TAG] ❌ Maximum render depth (${MAX_RENDER_DEPTH}) exceeded - possible infinite loop`));
377
+ console.error(chalk.red(`[RENDER TAG] Current rendering stack: ${Array.from(renderingStack).join(' -> ')}`));
378
+ currentRenderDepth--;
379
+ return `<div class="widget-error">Error: Maximum render depth exceeded</div>`;
380
+ }
381
+
382
+ // CRITICAL: Use scope.get() to properly resolve variables from the LiquidJS scope chain
383
+ // This correctly handles loop variables like 'widget' in {% for widget in widgets.hero %}
384
+
385
+ // Build context starting with currentRenderingContext
386
+ let fullContext = {};
387
+ if (currentRenderingContext) {
388
+ fullContext = { ...currentRenderingContext };
389
+ }
390
+
391
+ // Resolve file path - could be a string literal or a variable
392
+ let filePath = this.fileArg;
393
+
394
+ // If it's wrapped in quotes, it's a string literal
395
+ if ((filePath.startsWith('"') && filePath.endsWith('"')) ||
396
+ (filePath.startsWith("'") && filePath.endsWith("'"))) {
397
+ filePath = filePath.slice(1, -1);
398
+ } else {
399
+ // It's a variable - use scope.get() to resolve it properly (this handles loop variables!)
400
+ try {
401
+ // For paths like 'widget.template_path', we need to resolve via scope.get()
402
+ const resolvedValue = await scope.get(filePath.split('.'));
403
+ if (resolvedValue && typeof resolvedValue === 'string') {
404
+ filePath = resolvedValue;
405
+ } else {
406
+ currentRenderDepth--;
407
+ return '';
408
+ }
409
+ } catch (error) {
410
+ currentRenderDepth--;
411
+ return '';
412
+ }
413
+ }
414
+
415
+ // Parse hash parameters (e.g., widget: widget, settings: settings)
416
+ const renderContext = { ...fullContext };
417
+ if (this.hashArgs) {
418
+ // Parse hash args like "widget: widget, settings: settings"
419
+ const hashPairs = this.hashArgs.split(',').map(pair => pair.trim());
420
+ for (const pair of hashPairs) {
421
+ const colonIndex = pair.indexOf(':');
422
+ if (colonIndex > 0) {
423
+ const key = pair.substring(0, colonIndex).trim();
424
+ const valueVar = pair.substring(colonIndex + 1).trim();
425
+ if (key && valueVar) {
426
+ // Use scope.get() to resolve the value variable (handles loop variables correctly!)
427
+ try {
428
+ const value = await scope.get(valueVar.split('.'));
429
+ if (value !== undefined && value !== null) {
430
+ renderContext[key] = value;
431
+ }
432
+ } catch (error) {
433
+ // Silently skip unresolved hash params
434
+ }
435
+ }
436
+ }
437
+ }
438
+ }
439
+
440
+ // Handle widget paths
441
+ if (filePath.startsWith('widgets/')) {
442
+ const widgetName = filePath.replace(/^widgets\//, '').replace(/\.liquid$/, '');
443
+ const widgetPath = path.join(themePath, 'widgets', `${widgetName}.liquid`);
444
+
445
+ // Create unique render key based on widget ID (if available) or path
446
+ const widgetId = renderContext.widget?.id || widgetName;
447
+ const renderKey = `${widgetName}:${widgetId}`;
448
+
449
+ // Check for recursive rendering of the same widget
450
+ if (renderingStack.has(renderKey)) {
451
+ // Only log in debug mode to reduce noise - the guard is working correctly
452
+ if (process.env.DEBUG) {
453
+ console.warn(chalk.yellow(`[RENDER TAG] Skipping duplicate render of ${renderKey}`));
454
+ }
455
+ currentRenderDepth--;
456
+ return ''; // Skip duplicate render
457
+ }
458
+
459
+ // Add to rendering stack
460
+ renderingStack.add(renderKey);
461
+
462
+ try {
463
+ if (fs.existsSync(widgetPath)) {
464
+ // Render with isolated scope (render tag behavior) - use renderContext which includes hash params
465
+ const widgetContent = fs.readFileSync(widgetPath, 'utf8');
466
+ const rendered = await liquid.parseAndRender(widgetContent, renderContext);
467
+ return rendered;
468
+ } else {
469
+ // Try alternative paths
470
+ const altPath = path.join(themePath, 'widgets', 'shared', `${widgetName}.liquid`);
471
+ if (fs.existsSync(altPath)) {
472
+ const widgetContent = fs.readFileSync(altPath, 'utf8');
473
+ const rendered = await liquid.parseAndRender(widgetContent, renderContext);
474
+ return rendered;
475
+ }
476
+ console.error(chalk.red(`[RENDER TAG] ❌ Widget template not found: ${widgetPath}`));
477
+ }
478
+ } finally {
479
+ // Remove from rendering stack
480
+ renderingStack.delete(renderKey);
481
+ currentRenderDepth--;
482
+ }
483
+ return '';
484
+ }
485
+
486
+ // Handle snippet paths
487
+ if (filePath.startsWith('snippets/')) {
488
+ const snippetName = filePath.replace(/^snippets\//, '').replace(/\.liquid$/, '');
489
+ const snippetPath = path.join(themePath, 'snippets', `${snippetName}.liquid`);
490
+ try {
491
+ if (fs.existsSync(snippetPath)) {
492
+ const rendered = await liquid.parseAndRender(fs.readFileSync(snippetPath, 'utf8'), renderContext);
493
+ return rendered;
494
+ }
495
+ } finally {
496
+ currentRenderDepth--;
497
+ }
498
+ return '';
499
+ }
500
+
501
+ // Default behavior - try to resolve file
502
+ try {
503
+ const resolvedPath = path.join(themePath, filePath);
504
+ if (fs.existsSync(resolvedPath)) {
505
+ const rendered = await liquid.parseAndRender(fs.readFileSync(resolvedPath, 'utf8'), renderContext);
506
+ currentRenderDepth--;
507
+ return rendered;
508
+ }
509
+ // Try with .liquid extension
510
+ const resolvedPathWithExt = `${resolvedPath}.liquid`;
511
+ if (fs.existsSync(resolvedPathWithExt)) {
512
+ const rendered = await liquid.parseAndRender(fs.readFileSync(resolvedPathWithExt, 'utf8'), renderContext);
513
+ currentRenderDepth--;
514
+ return rendered;
515
+ }
516
+ } catch (error) {
517
+ console.warn(`[RENDER] Error rendering file ${filePath}:`, error.message);
518
+ }
519
+
520
+ currentRenderDepth--;
521
+ return '';
522
+ }
523
+ });
524
+ }
525
+
526
+ /**
527
+ * Render a single widget to HTML
528
+ * @param {Object} widget - Widget object
529
+ * @param {Object} context - Rendering context
530
+ * @param {string} themePath - Theme path
531
+ * @returns {Promise<string>} Rendered widget HTML
532
+ */
533
+ async function renderWidget(widget, context, themePath) {
534
+ if (!widget || !widget.template_path) {
535
+ return `<div class="widget-error">Widget missing template_path</div>`;
536
+ }
537
+
538
+ try {
539
+ const widgetPath = path.join(themePath, `${widget.template_path}.liquid`);
540
+ if (!fs.existsSync(widgetPath)) {
541
+ // Try without .liquid extension
542
+ const altPath = path.join(themePath, widget.template_path);
543
+ if (!fs.existsSync(altPath)) {
544
+ return `<div class="widget-error">Widget template not found: ${widget.template_path}</div>`;
545
+ }
546
+ // Use alt path
547
+ const widgetContent = fs.readFileSync(altPath, 'utf8');
548
+ const widgetContext = {
549
+ ...context,
550
+ widget: widget
551
+ };
552
+ return await liquid.parseAndRender(widgetContent, widgetContext);
553
+ }
554
+
555
+ const widgetContent = fs.readFileSync(widgetPath, 'utf8');
556
+ const widgetContext = {
557
+ ...context,
558
+ widget: widget
559
+ };
560
+ const rendered = await liquid.parseAndRender(widgetContent, widgetContext);
561
+ return rendered;
562
+ } catch (error) {
563
+ console.error(chalk.red(`[WIDGET RENDER] ❌ Error rendering widget ${widget.type}: ${error.message}`));
564
+ return `<div class="widget-error">Error rendering widget: ${error.message}</div>`;
565
+ }
566
+ }
567
+
568
+ /**
569
+ * Render template with layout support
570
+ * @param {Liquid} liquid - LiquidJS engine
571
+ * @param {string} templatePath - Template path (relative to theme, e.g., 'templates/index')
572
+ * @param {Object} context - Template context
573
+ * @param {string} themePath - Theme path
574
+ * @returns {Promise<string>} Rendered HTML
575
+ */
576
+ /**
577
+ * Pre-process template content to transform render_widget filter calls into tag calls
578
+ * This allows the filter syntax {{ widget | render_widget }} to work without modifying theme files
579
+ */
580
+ function preprocessTemplate(content) {
581
+ if (!content || typeof content !== 'string') {
582
+ return content;
583
+ }
584
+
585
+ // Replace {{ widget | render_widget }} with {% render_widget widget %}
586
+ const filterPattern = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*\|\s*render_widget\s*\}\}/gs;
587
+
588
+ // Pattern for widget loops that might need rendering code injected
589
+ const widgetLoopPattern = /(\{%\s*for\s+widget\s+in\s+[\w.]+\s*%\})([\s\S]*?)(\{%\s*endfor\s*%\})/g;
590
+
591
+ // Transform filter syntax to tag syntax
592
+ let transformed = content.replace(filterPattern, (match, variable) => {
593
+ return `{% render_widget ${variable} %}`;
594
+ });
595
+
596
+ // Check for widget loops with empty wrappers and inject rendering code
597
+ widgetLoopPattern.lastIndex = 0;
598
+ let loopMatch;
599
+ while ((loopMatch = widgetLoopPattern.exec(transformed)) !== null) {
600
+ const [fullMatch, forTag, loopBody, endforTag] = loopMatch;
601
+ const hasWrapper = loopBody.includes('theme-widget-wrapper');
602
+ const hasRendering = loopBody.includes('render_widget') || loopBody.includes('{% render');
603
+
604
+ if (hasWrapper && !hasRendering) {
605
+ const wrapperDivPattern = /(<div[^>]*theme-widget-wrapper[^>]*>)([\s\S]*?)(<\/div>)/;
606
+ const wrapperMatch = loopBody.match(wrapperDivPattern);
607
+
608
+ if (wrapperMatch) {
609
+ const [wrapperFull, openingDiv, innerContent, closingDiv] = wrapperMatch;
610
+ if (!innerContent.trim() || innerContent.trim().length < 10) {
611
+ const injectedContent = `${openingDiv}
612
+ {% if widget and widget.template_path %}
613
+ {% render widget.template_path, widget: widget, settings: settings %}
614
+ {% else %}
615
+ <div class="widget-error">Widget template not found</div>
616
+ {% endif %}
617
+ ${closingDiv}`;
618
+ transformed = transformed.replace(wrapperFull, injectedContent);
619
+ }
620
+ }
621
+ }
622
+ }
623
+
624
+ return transformed;
625
+ }
626
+
627
+ /**
628
+ * Render a single widget to HTML
629
+ * @param {Object} widget - Widget object
630
+ * @param {Object} context - Rendering context
631
+ * @param {string} themePath - Theme path
632
+ * @returns {Promise<string>} Rendered widget HTML
633
+ */
634
+ async function renderWidget(widget, context, themePath) {
635
+ if (!widget || !widget.template_path) {
636
+ return `<div class="widget-error">Widget missing template_path</div>`;
637
+ }
638
+
639
+ try {
640
+ const widgetPath = path.join(themePath, `${widget.template_path}.liquid`);
641
+ if (!fs.existsSync(widgetPath)) {
642
+ // Try without .liquid extension
643
+ const altPath = path.join(themePath, widget.template_path);
644
+ if (!fs.existsSync(altPath)) {
645
+ return `<div class="widget-error">Widget template not found: ${widget.template_path}</div>`;
646
+ }
647
+ // Use alt path
648
+ const widgetContent = fs.readFileSync(altPath, 'utf8');
649
+ const widgetContext = {
650
+ ...context,
651
+ widget: widget
652
+ };
653
+ return await liquid.parseAndRender(widgetContent, widgetContext);
654
+ }
655
+
656
+ const widgetContent = fs.readFileSync(widgetPath, 'utf8');
657
+ const widgetContext = {
658
+ ...context,
659
+ widget: widget
660
+ };
661
+ const rendered = await liquid.parseAndRender(widgetContent, widgetContext);
662
+ return rendered;
663
+ } catch (error) {
664
+ console.error(chalk.red(`[WIDGET RENDER] ❌ Error rendering widget ${widget.type}: ${error.message}`));
665
+ return `<div class="widget-error">Error rendering widget: ${error.message}</div>`;
666
+ }
667
+ }
668
+
669
+ async function renderWithLayout(liquid, templatePath, context, themePath) {
670
+ try {
671
+ // Store context globally so sections can access it
672
+ const previousContext = currentRenderingContext;
673
+ currentRenderingContext = context;
674
+
675
+ try {
676
+ const fullTemplatePath = path.join(themePath, `${templatePath}.liquid`);
677
+
678
+ if (!fs.existsSync(fullTemplatePath)) {
679
+ throw new Error(`Template not found: ${fullTemplatePath}`);
680
+ }
681
+
682
+ // Debug: Log context data for templates
683
+ const contextSummary = {
684
+ products: context.products?.length || 0,
685
+ widgets: Object.keys(context.widgets || {}).map(s => `${s}:${context.widgets[s]?.length || 0}`).join(', '),
686
+ categories: context.categories?.length || 0,
687
+ brands: context.brands?.length || 0,
688
+ menus: context.menus?.length || 0,
689
+ cart: context.cart?.itemCount || 0
690
+ };
691
+ console.log(`[RENDER] ${templatePath} - Context:`, JSON.stringify(contextSummary));
692
+
693
+ let templateContent = fs.readFileSync(fullTemplatePath, 'utf-8');
694
+
695
+ // Pre-process template to convert render_widget filter to tag
696
+ templateContent = preprocessTemplate(templateContent);
697
+
698
+ // DEBUG: Check if template uses render_widget filter
699
+ const usesRenderWidgetFilter = templateContent.includes('render_widget');
700
+ if (usesRenderWidgetFilter) {
701
+ console.log(chalk.yellow(`[RENDER] ⚠️ Template uses render_widget filter - checking if filter is available...`));
702
+ const hasFilter = liquid.filters && liquid.filters['render_widget'];
703
+ console.log(chalk.yellow(`[RENDER] Filter available: ${hasFilter ? '✅' : '❌'}`));
704
+ }
705
+
706
+ // Check for layout directive
707
+ const layoutMatch = templateContent.match(/\{%\s*layout\s+['"](.+?)['"]\s*%\}/);
708
+
709
+ if (layoutMatch) {
710
+ let layoutPath = layoutMatch[1]; // e.g., 'layout/theme' or 'theme'
711
+
712
+ // Remove layout directive from template
713
+ const contentWithoutLayout = templateContent.replace(/\{%\s*layout\s+['"](.+?)['"]\s*%\}/, '').trim();
714
+
715
+ // Render template content first
716
+ // Wrap in try-catch to catch any filter-related errors
717
+ let renderedContent;
718
+ try {
719
+ renderedContent = await liquid.parseAndRender(contentWithoutLayout, context);
720
+ } catch (renderError) {
721
+ console.error(chalk.red(`[RENDER] ❌ Error rendering template content:`));
722
+ console.error(chalk.red(` Error: ${renderError.message}`));
723
+ if (renderError.stack) {
724
+ console.error(chalk.red(` Stack: ${renderError.stack.split('\n').slice(0, 5).join('\n')}`));
725
+ }
726
+ // Check if error is related to render_widget filter
727
+ if (renderError.message && renderError.message.includes('render_widget')) {
728
+ console.error(chalk.yellow(`[RENDER] ⚠️ This error is related to render_widget filter - filter may not be working correctly`));
729
+ }
730
+ throw renderError;
731
+ }
732
+
733
+ // Render layout with content
734
+ const layoutContext = {
735
+ ...context,
736
+ content: renderedContent
737
+ };
738
+
739
+ // Resolve layout path
740
+ // If layoutPath doesn't include 'layout/', assume it's just the layout name
741
+ // and prepend 'layout/'
742
+ if (!layoutPath.includes('/')) {
743
+ layoutPath = `layout/${layoutPath}`;
744
+ }
745
+
746
+ // Build full layout file path
747
+ const layoutFile = path.join(themePath, `${layoutPath}.liquid`);
748
+
749
+ if (fs.existsSync(layoutFile)) {
750
+ let layoutContent = fs.readFileSync(layoutFile, 'utf-8');
751
+
752
+ // Set content_for_layout in context (Shopify-compatible approach)
753
+ // This is the proper way to pass content to layouts
754
+ layoutContext.content_for_layout = renderedContent;
755
+ layoutContext.content = renderedContent;
756
+
757
+ // Parse and render the layout with content in context
758
+ // The layout should use {{ content_for_layout }} or {{ content }} to output
759
+ return await liquid.parseAndRender(layoutContent, layoutContext);
760
+ } else {
761
+ // Try to create default layout if it doesn't exist
762
+ const layoutDir = path.dirname(layoutFile);
763
+ const layoutName = path.basename(layoutPath);
764
+
765
+ if (layoutName === 'theme' && !fs.existsSync(layoutFile)) {
766
+ console.warn(`[Liquid Engine] Layout not found: ${layoutFile}`);
767
+ console.warn(`[Liquid Engine] Creating default theme layout...`);
768
+ fs.ensureDirSync(layoutDir);
769
+
770
+ const defaultLayout = `<!DOCTYPE html>
771
+ <html lang="en">
772
+ <head>
773
+ <meta charset="UTF-8">
774
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
775
+ <title>{{ shop.name }}{% if page.title %} - {{ page.title }}{% endif %}</title>
776
+ </head>
777
+ <body>
778
+ {{ content }}
779
+ </body>
780
+ </html>`;
781
+ fs.writeFileSync(layoutFile, defaultLayout);
782
+
783
+ // Read the newly created layout
784
+ let layoutContent = fs.readFileSync(layoutFile, 'utf-8');
785
+ // Set content in context
786
+ layoutContext.content_for_layout = renderedContent;
787
+ layoutContext.content = renderedContent;
788
+ return await liquid.parseAndRender(layoutContent, layoutContext);
789
+ } else {
790
+ throw new Error(`Layout not found: ${layoutFile}`);
791
+ }
792
+ }
793
+ } else {
794
+ // No layout directive, render template directly
795
+ return await liquid.parseAndRender(templateContent, context);
796
+ }
797
+ } finally {
798
+ // Restore previous context
799
+ currentRenderingContext = previousContext;
800
+ }
801
+ } catch (error) {
802
+ console.error('Error rendering template with layout:', error);
803
+ throw error;
804
+ } finally {
805
+ // Ensure context is cleared on error
806
+ currentRenderingContext = null;
807
+ }
808
+ }
809
+
810
+ /**
811
+ * Get current rendering context (for use in filters)
812
+ * @returns {Object|null} Current rendering context
813
+ */
814
+ function getCurrentContext() {
815
+ return currentRenderingContext;
816
+ }
817
+
818
+ module.exports = {
819
+ createLiquidEngine,
820
+ renderWithLayout,
821
+ getCurrentContext
822
+ };