@o2vend/theme-cli 1.0.33 → 1.0.35

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.
@@ -280,13 +280,25 @@ function registerCustomTags(liquid, themePath) {
280
280
  };
281
281
 
282
282
  // Override the include tag to handle widget and snippet paths
283
+ // CRITICAL: Include tag must parse hash arguments like 'product: product' to match production behavior
283
284
  liquid.registerTag('include', {
284
285
  parse: function(tagToken, remainTokens) {
285
- // Parse arguments - handle both 'snippets/pagination' and 'snippets/pagination', pagination formats
286
+ // Parse arguments - handle 'snippets/product-card', product: product format
286
287
  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, '');
288
+
289
+ // Split by comma to separate file path from hash parameters
290
+ const commaIndex = args.indexOf(',');
291
+ if (commaIndex > 0) {
292
+ this.fileArg = args.substring(0, commaIndex).trim();
293
+ this.hashArgs = args.substring(commaIndex + 1).trim();
294
+ } else {
295
+ this.fileArg = args;
296
+ this.hashArgs = '';
297
+ }
298
+
299
+ // Extract file path - remove quotes if present
300
+ const fileMatch = this.fileArg.match(/^['"]([^'"]+)['"]/);
301
+ this.file = fileMatch ? fileMatch[1] : this.fileArg.replace(/^['"]|['"]$/g, '');
290
302
  },
291
303
  render: async function(scope, hash) {
292
304
  let filePath = this.file;
@@ -298,7 +310,7 @@ function registerCustomTags(liquid, themePath) {
298
310
  // Clean up file path
299
311
  filePath = filePath.trim().replace(/^['"]|['"]$/g, '');
300
312
 
301
- // Get full context for snippets/widgets
313
+ // Get full context for snippets/widgets - merge parent scope (for shop, settings, etc.)
302
314
  const scopeContexts = Array.isArray(scope?.contexts) ? scope.contexts : [];
303
315
  const primaryScope = scopeContexts.length > 0
304
316
  ? scopeContexts[0]
@@ -309,12 +321,37 @@ function registerCustomTags(liquid, themePath) {
309
321
  ? { ...currentRenderingContext, ...primaryScope }
310
322
  : primaryScope;
311
323
 
324
+ // CRITICAL: Parse hash arguments if provided (e.g., "product: product")
325
+ // Hash params override parent scope values, matching LiquidJS include behavior
326
+ const includeContext = { ...fullContext };
327
+ if (this.hashArgs) {
328
+ const hashPairs = this.hashArgs.split(',').map(pair => pair.trim());
329
+ for (const pair of hashPairs) {
330
+ const colonIndex = pair.indexOf(':');
331
+ if (colonIndex > 0) {
332
+ const key = pair.substring(0, colonIndex).trim();
333
+ const valueVar = pair.substring(colonIndex + 1).trim();
334
+ if (key && valueVar) {
335
+ // Use scope.get() to resolve the value variable (handles loop variables correctly!)
336
+ try {
337
+ const value = await scope.get(valueVar.split('.'));
338
+ if (value !== undefined && value !== null) {
339
+ includeContext[key] = value;
340
+ }
341
+ } catch (error) {
342
+ // Silently skip unresolved hash params
343
+ }
344
+ }
345
+ }
346
+ }
347
+ }
348
+
312
349
  // Handle widget paths
313
350
  if (filePath.startsWith('widgets/')) {
314
351
  const widgetName = filePath.replace(/^widgets\//, '').replace(/\.liquid$/, '');
315
352
  const widgetPath = path.join(themePath, 'widgets', `${widgetName}.liquid`);
316
353
  if (fs.existsSync(widgetPath)) {
317
- return await liquid.parseAndRender(fs.readFileSync(widgetPath, 'utf8'), fullContext);
354
+ return await liquid.parseAndRender(fs.readFileSync(widgetPath, 'utf8'), includeContext);
318
355
  }
319
356
  }
320
357
 
@@ -323,7 +360,7 @@ function registerCustomTags(liquid, themePath) {
323
360
  const snippetName = filePath.replace(/^snippets\//, '').replace(/\.liquid$/, '');
324
361
  const snippetPath = path.join(themePath, 'snippets', `${snippetName}.liquid`);
325
362
  if (fs.existsSync(snippetPath)) {
326
- return await liquid.parseAndRender(fs.readFileSync(snippetPath, 'utf8'), fullContext);
363
+ return await liquid.parseAndRender(fs.readFileSync(snippetPath, 'utf8'), includeContext);
327
364
  } else {
328
365
  console.warn(`[INCLUDE] Snippet not found: ${snippetPath}`);
329
366
  }
@@ -332,19 +369,19 @@ function registerCustomTags(liquid, themePath) {
332
369
  // Try to resolve file in snippets directory (default location)
333
370
  const snippetPath = path.join(themePath, 'snippets', `${filePath}.liquid`);
334
371
  if (fs.existsSync(snippetPath)) {
335
- return await liquid.parseAndRender(fs.readFileSync(snippetPath, 'utf8'), fullContext);
372
+ return await liquid.parseAndRender(fs.readFileSync(snippetPath, 'utf8'), includeContext);
336
373
  }
337
374
 
338
375
  // Try direct path
339
376
  const resolvedPath = path.join(themePath, filePath);
340
377
  if (fs.existsSync(resolvedPath)) {
341
- return await liquid.parseAndRender(fs.readFileSync(resolvedPath, 'utf8'), fullContext);
378
+ return await liquid.parseAndRender(fs.readFileSync(resolvedPath, 'utf8'), includeContext);
342
379
  }
343
380
 
344
381
  // Try with .liquid extension
345
382
  const resolvedPathWithExt = `${resolvedPath}.liquid`;
346
383
  if (fs.existsSync(resolvedPathWithExt)) {
347
- return await liquid.parseAndRender(fs.readFileSync(resolvedPathWithExt, 'utf8'), fullContext);
384
+ return await liquid.parseAndRender(fs.readFileSync(resolvedPathWithExt, 'utf8'), includeContext);
348
385
  }
349
386
 
350
387
  console.warn(`[INCLUDE] File not found: ${filePath} (tried: ${snippetPath}, ${resolvedPath}, ${resolvedPathWithExt})`);
@@ -686,9 +723,44 @@ async function renderWithLayout(liquid, templatePath, context, themePath) {
686
723
  categories: context.categories?.length || 0,
687
724
  brands: context.brands?.length || 0,
688
725
  menus: context.menus?.length || 0,
689
- cart: context.cart?.itemCount || 0
726
+ cart: context.cart?.itemCount || 0,
727
+ collection: context.collection ? {
728
+ title: context.collection.title || context.collection.name,
729
+ products: context.collection.products?.length || 0
730
+ } : null
690
731
  };
691
732
  console.log(`[RENDER] ${templatePath} - Context:`, JSON.stringify(contextSummary));
733
+
734
+ // Detailed product logging
735
+ if (context.products && context.products.length > 0) {
736
+ const sampleProduct = context.products[0];
737
+ console.log(`[RENDER] ${templatePath} - Sample product data:`, {
738
+ id: sampleProduct.id,
739
+ title: sampleProduct.title || sampleProduct.name,
740
+ url: sampleProduct.url || sampleProduct.link,
741
+ price: sampleProduct.price || sampleProduct.sellingPrice,
742
+ stock: sampleProduct.stock,
743
+ inStock: sampleProduct.inStock,
744
+ hasImage: !!(sampleProduct.imageUrl || sampleProduct.thumbnailImage1),
745
+ imageUrl: sampleProduct.imageUrl || sampleProduct.thumbnailImage1?.url,
746
+ categoryId: sampleProduct.categoryId
747
+ });
748
+ } else {
749
+ console.warn(`[RENDER] ${templatePath} - ⚠️ No products in context!`);
750
+ }
751
+
752
+ // Check collection products
753
+ if (context.collection && context.collection.products) {
754
+ console.log(`[RENDER] ${templatePath} - Collection products: ${context.collection.products.length}`);
755
+ if (context.collection.products.length > 0) {
756
+ const sample = context.collection.products[0];
757
+ console.log(`[RENDER] ${templatePath} - Sample collection product:`, {
758
+ id: sample.id,
759
+ title: sample.title || sample.name,
760
+ url: sample.url || sample.link
761
+ });
762
+ }
763
+ }
692
764
 
693
765
  let templateContent = fs.readFileSync(fullTemplatePath, 'utf-8');
694
766
 
@@ -0,0 +1,322 @@
1
+ /**
2
+ * WebstoreAPI Fetcher (Real Mode)
3
+ * Thin HTTP client for public /webstoreapi/* endpoints only.
4
+ * No webstore internals, business logic, or service abstractions.
5
+ */
6
+
7
+ const axios = require('axios');
8
+
9
+ const DEFAULT_SECTIONS = ['hero', 'products', 'footer', 'content', 'header'];
10
+ const TIMEOUT = parseInt(process.env.O2VEND_API_TIMEOUT, 10) || 10000;
11
+ const DEBUG = process.env.DEBUG_WEBSTOREAPI === 'true' || process.env.DEBUG_WEBSTOREAPI === '1';
12
+
13
+ function logRequest(method, path, status, extra = {}) {
14
+ const msg = `[webstoreapi] ${method} ${path} ${status}`;
15
+ if (DEBUG || status >= 400) {
16
+ const parts = [msg];
17
+ if (extra.bodySnippet) parts.push(extra.bodySnippet);
18
+ if (extra.headers) parts.push(JSON.stringify(extra.headers));
19
+ console.log(parts.join(' '));
20
+ }
21
+ }
22
+
23
+ function truncate(v, max = 300) {
24
+ const s = typeof v === 'string' ? v : JSON.stringify(v);
25
+ return s.length <= max ? s : s.slice(0, max) + '...';
26
+ }
27
+
28
+ /**
29
+ * Derive webstoreapi base URL from O2VEND_API_BASE_URL.
30
+ * Strips /shopfront/api/v2 if present.
31
+ * @returns {string} e.g. https://sareesdemo.myo2vend.com/webstoreapi
32
+ */
33
+ function getWebstoreapiBaseUrl() {
34
+ let base = process.env.O2VEND_API_BASE_URL || '';
35
+ base = base.replace(/\/$/, '');
36
+ base = base.replace(/\/shopfront\/api\/v2\/?$/i, '');
37
+ base = base.replace(/\/webstoreapi\/?$/i, '');
38
+ return base ? `${base}/webstoreapi` : '';
39
+ }
40
+
41
+ /**
42
+ * Derive shopfront API base URL from O2VEND_API_BASE_URL.
43
+ * Used only for GET /storeinfo (store logo, favicon, name).
44
+ * @returns {string} e.g. https://sareesdemo.myo2vend.com/shopfront/api/v2
45
+ */
46
+ function getShopfrontBaseUrl() {
47
+ let base = process.env.O2VEND_API_BASE_URL || '';
48
+ base = base.replace(/\/$/, '');
49
+ base = base.replace(/\/webstoreapi\/?$/i, '');
50
+ if (/\/shopfront\/api\/v2\/?$/i.test(base)) return base.replace(/\/$/, '');
51
+ base = base.replace(/\/shopfront\/api\/v2\/?$/i, '');
52
+ return base ? `${base}/shopfront/api/v2` : '';
53
+ }
54
+
55
+ /**
56
+ * Fetch store info from shopfront API (GET /storeinfo).
57
+ * Used in real mode for shop.logo, shop.favicon, shop.name.
58
+ * Requires O2VEND_API_KEY. On failure, returns null.
59
+ * @returns {Promise<{ logoUrl?: string, favouriteIconUrl?: string, name?: string, settings?: object } | null>}
60
+ */
61
+ async function fetchStoreInfo() {
62
+ const base = getShopfrontBaseUrl();
63
+ const apiKey = process.env.O2VEND_API_KEY;
64
+ if (!base || !apiKey) return null;
65
+ const client = axios.create({
66
+ baseURL: base,
67
+ timeout: TIMEOUT,
68
+ headers: {
69
+ 'Content-Type': 'application/json',
70
+ 'X-O2VEND-SHOPFRONT-API-KEY': apiKey,
71
+ 'User-Agent': 'O2VEND-Theme-CLI/1.0'
72
+ }
73
+ });
74
+ try {
75
+ const res = await client.get('/storeinfo');
76
+ const data = res.data || {};
77
+ const logoUrl = data.logoUrl ?? data.logourl;
78
+ const favouriteIconUrl = data.favouriteIconUrl ?? data.favouriteiconurl;
79
+ const name = data.name ?? data.Name;
80
+ const settings = data.settings || data.Settings || {};
81
+ return {
82
+ logoUrl: logoUrl || null,
83
+ favouriteIconUrl: favouriteIconUrl || null,
84
+ name: name || null,
85
+ settings
86
+ };
87
+ } catch (e) {
88
+ if (DEBUG) console.log('[webstoreapi] GET /storeinfo failed:', e.response?.status || e.message);
89
+ return null;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Create axios instance for webstoreapi calls.
95
+ * @param {Object} [opts] - { headers: { Cookie } }
96
+ * @returns {{ baseUrl: string, get: Function, post: Function }}
97
+ */
98
+ function createFetcher(opts = {}) {
99
+ const baseUrl = getWebstoreapiBaseUrl();
100
+ const client = axios.create({
101
+ baseURL: baseUrl,
102
+ timeout: TIMEOUT,
103
+ headers: {
104
+ 'Content-Type': 'application/json',
105
+ 'User-Agent': 'O2VEND-Theme-CLI/1.0',
106
+ ...(opts.headers || {})
107
+ }
108
+ });
109
+
110
+ client.interceptors.response.use(
111
+ (r) => {
112
+ const method = (r.config?.method || 'get').toUpperCase();
113
+ const path = r.config?.url || r.config?.baseURL || '';
114
+ logRequest(method, path, r.status);
115
+ return r;
116
+ },
117
+ (err) => {
118
+ const method = (err.config?.method || 'get').toUpperCase();
119
+ const path = err.config?.url || err.config?.baseURL || '';
120
+ const status = err.response?.status;
121
+ const data = err.response?.data;
122
+ const bodySnippet = data != null ? truncate(data) : err.message;
123
+ logRequest(method, path, status || 'ERR', { bodySnippet });
124
+ if (status >= 400 && typeof data === 'object' && data.message) {
125
+ console.log(`[webstoreapi] error message: ${data.message}`);
126
+ }
127
+ return Promise.reject(err);
128
+ }
129
+ );
130
+
131
+ return {
132
+ baseUrl,
133
+ get: (path, config = {}) => client.get(path, config).then((r) => r.data),
134
+ post: (path, body, config = {}) => client.post(path, body, config).then((r) => r.data)
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Convert products query (POST-style) to query string for GET /webstoreapi/products.
140
+ * @param {Object} p - { limit, offset, filter, searchText, ... }
141
+ * @returns {string}
142
+ */
143
+ function productsQueryToParams(p) {
144
+ const q = new URLSearchParams();
145
+ const limit = p.limit != null ? parseInt(p.limit, 10) : 20;
146
+ const page = p.page != null ? parseInt(p.page, 10) : (p.offset != null ? Math.floor(parseInt(p.offset, 10) / limit) + 1 : 1);
147
+ q.set('page', String(Math.max(1, page)));
148
+ q.set('limit', String(Math.max(1, limit)));
149
+ if (p.searchText) q.set('searchText', p.searchText);
150
+ const cat = p.categoryId || (p.filter && p.filter.category) || '';
151
+ const brand = p.brandId || (p.filter && p.filter.brand) || '';
152
+ q.set('filter[category]', String(cat));
153
+ q.set('filter[brand]', String(brand));
154
+ if (p.sort && typeof p.sort === 'object' && p.sort.field) {
155
+ q.set('sort[field]', p.sort.field);
156
+ }
157
+ return q.toString();
158
+ }
159
+
160
+ /**
161
+ * Convert categories query to query string for GET /webstoreapi/categories.
162
+ * @param {Object} p - { limit, offset }
163
+ * @returns {string}
164
+ */
165
+ function categoriesQueryToParams(p) {
166
+ const q = new URLSearchParams();
167
+ if (p.limit != null) q.set('limit', String(p.limit));
168
+ if (p.offset != null) q.set('offset', String(p.offset));
169
+ return q.toString();
170
+ }
171
+
172
+ /**
173
+ * Fetch products. GET /webstoreapi/products?...
174
+ * @param {Object} params - { limit, offset, filter, searchText, ... }
175
+ * @param {Object} [opts] - { headers: { Cookie } }
176
+ * @returns {Promise<{ products?: Array, Products?: Array, totalCount?: number, TotalCount?: number }>}
177
+ */
178
+ async function fetchProducts(params = {}, opts = {}) {
179
+ const { get } = createFetcher(opts);
180
+ const qs = productsQueryToParams(params);
181
+ const path = qs ? `/products?${qs}` : '/products';
182
+ return get(path);
183
+ }
184
+
185
+ /**
186
+ * Fetch product by ID. GET /webstoreapi/products/:productId
187
+ * @param {string} id
188
+ * @param {Object} [opts]
189
+ * @returns {Promise<Object>}
190
+ */
191
+ async function fetchProductById(id, opts = {}) {
192
+ const { get } = createFetcher(opts);
193
+ return get(`/products/${encodeURIComponent(id)}`);
194
+ }
195
+
196
+ /**
197
+ * Fetch categories. GET /webstoreapi/categories?...
198
+ * @param {Object} params - { limit, offset }
199
+ * @param {Object} [opts]
200
+ * @returns {Promise<{ categories?: Array, data?: { categories?: Array } }>}
201
+ */
202
+ async function fetchCategories(params = {}, opts = {}) {
203
+ const { get } = createFetcher(opts);
204
+ const qs = categoriesQueryToParams(params);
205
+ const path = qs ? `/categories?${qs}` : '/categories';
206
+ return get(path);
207
+ }
208
+
209
+ /**
210
+ * Fetch brands. POST /webstoreapi/brands with body.
211
+ * @param {Object} params - { limit, offset }
212
+ * @param {Object} [opts]
213
+ * @returns {Promise<{ brands?: Array, data?: { brands?: Array } }>}
214
+ */
215
+ async function fetchBrands(params = {}, opts = {}) {
216
+ const { post } = createFetcher(opts);
217
+ return post('/brands', params || {});
218
+ }
219
+
220
+ /**
221
+ * Fetch menus. GET /webstoreapi/menus
222
+ * @param {Object} [opts]
223
+ * @returns {Promise<Array|{ data?: { menus?: Array }, menus?: Array }>}
224
+ */
225
+ async function fetchMenus(opts = {}) {
226
+ const { get } = createFetcher(opts);
227
+ const data = await get('/menus');
228
+ if (Array.isArray(data)) return data;
229
+ return data?.data?.menus || data?.menus || [];
230
+ }
231
+
232
+ /**
233
+ * Fetch menu by ID. GET /webstoreapi/menus/:id
234
+ * @param {string} id
235
+ * @param {Object} [opts]
236
+ * @returns {Promise<Object>}
237
+ */
238
+ async function fetchMenuById(id, opts = {}) {
239
+ const { get } = createFetcher(opts);
240
+ return get(`/menus/${encodeURIComponent(id)}`);
241
+ }
242
+
243
+ /**
244
+ * Fetch cart. GET /webstoreapi/carts (session-based; pass Cookie).
245
+ * @param {Object} [opts] - { headers: { Cookie: req.headers.cookie } }
246
+ * @returns {Promise<{ data?: Object }>}
247
+ */
248
+ async function fetchCart(opts = {}) {
249
+ const { get } = createFetcher(opts);
250
+ return get('/carts');
251
+ }
252
+
253
+ /**
254
+ * Fetch widgets for a section. GET /webstoreapi/sections/widgets/:page/:section
255
+ * @param {string} page - e.g. 'home'
256
+ * @param {string} section - e.g. 'hero', 'products'
257
+ * @param {Object} [opts]
258
+ * @returns {Promise<{ widgets?: Array }>}
259
+ */
260
+ async function fetchSectionsWidgets(page, section, opts = {}) {
261
+ const { get } = createFetcher(opts);
262
+ const data = await get(`/sections/widgets/${encodeURIComponent(page)}/${encodeURIComponent(section)}`);
263
+ const list = Array.isArray(data?.widgets) ? data.widgets : [];
264
+ return { widgets: list };
265
+ }
266
+
267
+ /**
268
+ * Fetch all sections widgets and merge into { hero: [], products: [], ... }.
269
+ * @param {string} [page] - e.g. 'home'
270
+ * @param {string[]} [sections]
271
+ * @param {Object} [opts]
272
+ * @returns {Promise<Object>}
273
+ */
274
+ async function fetchWidgetsBySections(page = 'home', sections = DEFAULT_SECTIONS, opts = {}) {
275
+ const out = {};
276
+ for (const s of sections) {
277
+ out[s] = [];
278
+ }
279
+ for (const section of sections) {
280
+ try {
281
+ const { widgets } = await fetchSectionsWidgets(page, section, opts);
282
+ out[section] = Array.isArray(widgets) ? widgets : [];
283
+ } catch (e) {
284
+ out[section] = [];
285
+ }
286
+ }
287
+ return out;
288
+ }
289
+
290
+ /**
291
+ * Fetch CMS page. GET /webstoreapi/pages/:handle
292
+ * @param {string} handle
293
+ * @param {Object} [opts]
294
+ * @returns {Promise<Object|null>} null on 404
295
+ */
296
+ async function fetchPage(handle, opts = {}) {
297
+ const { get } = createFetcher(opts);
298
+ try {
299
+ return await get(`/pages/${encodeURIComponent(handle)}`);
300
+ } catch (e) {
301
+ if (e.response?.status === 404) return null;
302
+ throw e;
303
+ }
304
+ }
305
+
306
+ module.exports = {
307
+ getWebstoreapiBaseUrl,
308
+ getShopfrontBaseUrl,
309
+ createFetcher,
310
+ fetchProducts,
311
+ fetchProductById,
312
+ fetchCategories,
313
+ fetchBrands,
314
+ fetchMenus,
315
+ fetchMenuById,
316
+ fetchCart,
317
+ fetchSectionsWidgets,
318
+ fetchWidgetsBySections,
319
+ fetchPage,
320
+ fetchStoreInfo,
321
+ DEFAULT_SECTIONS
322
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o2vend/theme-cli",
3
- "version": "1.0.33",
3
+ "version": "1.0.35",
4
4
  "description": "O2VEND Theme Development CLI - Standalone tool for local theme development",
5
5
  "bin": {
6
6
  "o2vend": "./bin/o2vend"
@@ -22,28 +22,28 @@
22
22
  "unpublish-and-republish": "node scripts/unpublish-and-republish.js"
23
23
  },
24
24
  "dependencies": {
25
+ "archiver": "^6.0.1",
26
+ "axios": "^1.6.0",
27
+ "boxen": "^7.1.1",
28
+ "chalk": "^4.1.2",
29
+ "chokidar": "^3.5.3",
25
30
  "commander": "^11.0.0",
31
+ "dotenv": "^16.3.1",
26
32
  "express": "^4.18.2",
33
+ "fs-extra": "^11.1.1",
34
+ "glob": "^10.3.10",
35
+ "http-proxy-middleware": "^2.0.6",
36
+ "inquirer": "^9.2.0",
27
37
  "liquidjs": "^10.7.0",
28
- "axios": "^1.6.0",
38
+ "listr": "^0.14.3",
29
39
  "lodash": "^4.17.21",
30
40
  "moment": "^2.29.4",
41
+ "node-cache": "^5.1.2",
42
+ "open": "^8.4.2",
43
+ "ora": "^5.4.1",
31
44
  "slugify": "^1.6.6",
32
- "chokidar": "^3.5.3",
33
45
  "socket.io": "^4.7.0",
34
- "chalk": "^4.1.2",
35
- "inquirer": "^9.2.0",
36
- "ora": "^5.4.1",
37
- "boxen": "^7.1.1",
38
- "table": "^6.8.3",
39
- "listr": "^0.14.3",
40
- "dotenv": "^16.3.1",
41
- "archiver": "^6.0.1",
42
- "glob": "^10.3.10",
43
- "fs-extra": "^11.1.1",
44
- "node-cache": "^5.1.2",
45
- "http-proxy-middleware": "^2.0.6",
46
- "open": "^8.4.2"
46
+ "table": "^6.8.3"
47
47
  },
48
48
  "engines": {
49
49
  "node": ">=18.0.0"