@o2vend/theme-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of @o2vend/theme-cli might be problematic. Click here for more details.

@@ -0,0 +1,482 @@
1
+ /**
2
+ * Development Server
3
+ * Express server for local theme development with hot reload
4
+ */
5
+
6
+ const express = require('express');
7
+ const path = require('path');
8
+ const fs = require('fs-extra');
9
+ const { createLiquidEngine, renderWithLayout } = require('./liquid-engine');
10
+ const WidgetService = require('./widget-service');
11
+ const O2VendApiClient = require('./api-client');
12
+ const { setupHotReload } = require('./hot-reload');
13
+ const chalk = require('chalk');
14
+
15
+ class DevServer {
16
+ constructor(options = {}) {
17
+ this.themePath = options.themePath || process.cwd();
18
+ this.port = options.port || 3000;
19
+ this.host = options.host || 'localhost';
20
+ this.mode = options.mode || 'mock'; // 'mock' or 'real'
21
+ this.mockApiPort = options.mockApiPort || 3001;
22
+ this.openBrowser = options.open !== false;
23
+
24
+ this.app = express();
25
+ this.server = null;
26
+ this.io = null;
27
+ this.liquid = null;
28
+ this.widgetService = null;
29
+ this.apiClient = null;
30
+ this.mockApi = null;
31
+
32
+ // Setup server
33
+ this.setupMiddleware();
34
+ this.setupRoutes();
35
+ }
36
+
37
+ /**
38
+ * Setup Express middleware
39
+ */
40
+ setupMiddleware() {
41
+ // Body parser
42
+ this.app.use(express.json());
43
+ this.app.use(express.urlencoded({ extended: true }));
44
+
45
+ // Static files (theme assets)
46
+ this.app.use('/assets', express.static(path.join(this.themePath, 'assets')));
47
+
48
+ // Serve images from assets
49
+ this.app.use('/images', express.static(path.join(this.themePath, 'assets')));
50
+
51
+ // Request logging
52
+ this.app.use((req, res, next) => {
53
+ const method = req.method;
54
+ const url = req.url;
55
+ const status = res.statusCode;
56
+ console.log(chalk.gray(`${method} ${url} ${status}`));
57
+ next();
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Setup API client and widget service
63
+ */
64
+ async setupServices() {
65
+ if (this.mode === 'real') {
66
+ // Real API mode - create API client from environment
67
+ const tenantId = process.env.O2VEND_TENANT_ID;
68
+ const apiKey = process.env.O2VEND_API_KEY;
69
+ const baseUrl = process.env.O2VEND_API_BASE_URL;
70
+
71
+ if (!tenantId || !apiKey || !baseUrl) {
72
+ console.warn(chalk.yellow('⚠️ Real API mode requires O2VEND_TENANT_ID, O2VEND_API_KEY, and O2VEND_API_BASE_URL'));
73
+ console.warn(chalk.yellow(' Falling back to mock mode'));
74
+ this.mode = 'mock';
75
+ } else {
76
+ this.apiClient = new O2VendApiClient(tenantId, apiKey, baseUrl);
77
+ }
78
+ }
79
+
80
+ // In mock mode, create API client pointing to mock API (will be proxied)
81
+ if (this.mode === 'mock') {
82
+ // Mock API will run on mockApiPort, proxy will route /shopfront/api to it
83
+ // So we create client pointing to main server, proxy will handle routing
84
+ this.apiClient = new O2VendApiClient('mock-tenant', 'mock-key', `http://localhost:${this.port}`);
85
+ }
86
+
87
+ // Create widget service (works with mock or real API)
88
+ this.widgetService = new WidgetService(this.apiClient, {
89
+ theme: path.basename(this.themePath),
90
+ themePath: this.themePath
91
+ });
92
+
93
+ // Create Liquid engine
94
+ this.liquid = createLiquidEngine(this.themePath, {
95
+ cache: false // Disable cache in dev mode
96
+ });
97
+
98
+ // Register widget render filter
99
+ this.setupWidgetFilter();
100
+ }
101
+
102
+ /**
103
+ * Register widget render filter
104
+ */
105
+ setupWidgetFilter() {
106
+ this.liquid.registerFilter('render_widget', async (widget, context) => {
107
+ if (!widget || !widget.type) {
108
+ return '';
109
+ }
110
+
111
+ try {
112
+ const templateSlug = this.widgetService.getTemplateSlug(widget.type);
113
+ const widgetPath = path.join(this.themePath, 'widgets', `${templateSlug}.liquid`);
114
+
115
+ if (!fs.existsSync(widgetPath)) {
116
+ console.warn(`[WIDGET] Template not found: ${widgetPath}`);
117
+ return '';
118
+ }
119
+
120
+ const widgetContent = fs.readFileSync(widgetPath, 'utf8');
121
+ const widgetContext = {
122
+ ...context,
123
+ widget: widget
124
+ };
125
+
126
+ return await this.liquid.parseAndRender(widgetContent, widgetContext);
127
+ } catch (error) {
128
+ console.error(`[WIDGET] Error rendering widget ${widget.type}:`, error.message);
129
+ return '';
130
+ }
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Setup Express routes
136
+ */
137
+ setupRoutes() {
138
+ // Homepage
139
+ this.app.get('/', async (req, res, next) => {
140
+ try {
141
+ const context = await this.buildContext(req, 'home');
142
+ const html = await renderWithLayout(this.liquid, 'templates/index', context, this.themePath);
143
+ res.send(html);
144
+ } catch (error) {
145
+ next(error);
146
+ }
147
+ });
148
+
149
+ // Product page
150
+ this.app.get('/products/:handle', async (req, res, next) => {
151
+ try {
152
+ const context = await this.buildContext(req, 'product', { productHandle: req.params.handle });
153
+ const html = await renderWithLayout(this.liquid, 'templates/product', context, this.themePath);
154
+ res.send(html);
155
+ } catch (error) {
156
+ next(error);
157
+ }
158
+ });
159
+
160
+ // Collection page
161
+ this.app.get('/collections/:handle', async (req, res, next) => {
162
+ try {
163
+ const context = await this.buildContext(req, 'collection', { collectionHandle: req.params.handle });
164
+ const html = await renderWithLayout(this.liquid, 'templates/collection', context, this.themePath);
165
+ res.send(html);
166
+ } catch (error) {
167
+ next(error);
168
+ }
169
+ });
170
+
171
+ // Cart page
172
+ this.app.get('/cart', async (req, res, next) => {
173
+ try {
174
+ const context = await this.buildContext(req, 'cart');
175
+ const html = await renderWithLayout(this.liquid, 'templates/cart', context, this.themePath);
176
+ res.send(html);
177
+ } catch (error) {
178
+ next(error);
179
+ }
180
+ });
181
+
182
+ // Search page
183
+ this.app.get('/search', async (req, res, next) => {
184
+ try {
185
+ const context = await this.buildContext(req, 'search', { query: req.query.q });
186
+ const html = await renderWithLayout(this.liquid, 'templates/search', context, this.themePath);
187
+ res.send(html);
188
+ } catch (error) {
189
+ next(error);
190
+ }
191
+ });
192
+
193
+ // Custom page
194
+ this.app.get('/pages/:handle', async (req, res, next) => {
195
+ try {
196
+ const context = await this.buildContext(req, 'page', { pageHandle: req.params.handle });
197
+ const html = await renderWithLayout(this.liquid, 'templates/page', context, this.themePath);
198
+ res.send(html);
199
+ } catch (error) {
200
+ next(error);
201
+ }
202
+ });
203
+
204
+ // API proxy (for real API mode)
205
+ if (this.mode === 'real' && this.apiClient) {
206
+ this.app.use('/api', (req, res, next) => {
207
+ // Proxy API requests to real API
208
+ // This is handled by mock API in mock mode
209
+ next();
210
+ });
211
+ }
212
+
213
+ // Error handler
214
+ this.app.use((error, req, res, next) => {
215
+ console.error(chalk.red('[ERROR]'), error);
216
+ res.status(500).send(`
217
+ <!DOCTYPE html>
218
+ <html>
219
+ <head>
220
+ <title>Error</title>
221
+ <style>
222
+ body { font-family: monospace; padding: 2rem; }
223
+ h1 { color: #d32f2f; }
224
+ pre { background: #f5f5f5; padding: 1rem; border-radius: 4px; }
225
+ </style>
226
+ </head>
227
+ <body>
228
+ <h1>Error Rendering Page</h1>
229
+ <pre>${error.message}\n\n${error.stack}</pre>
230
+ </body>
231
+ </html>
232
+ `);
233
+ });
234
+ }
235
+
236
+ /**
237
+ * Build template context
238
+ * @param {Object} req - Express request
239
+ * @param {string} pageType - Page type (home, product, collection, etc.)
240
+ * @param {Object} extra - Extra context data
241
+ * @returns {Promise<Object>} Template context
242
+ */
243
+ async buildContext(req, pageType, extra = {}) {
244
+ const context = {
245
+ shop: {},
246
+ tenant: {},
247
+ page: {
248
+ type: pageType,
249
+ ...extra
250
+ },
251
+ products: [],
252
+ collections: [],
253
+ categories: [],
254
+ brands: [],
255
+ cart: { items: [], total: 0, itemCount: 0 },
256
+ widgets: {},
257
+ settings: {},
258
+ ...extra
259
+ };
260
+
261
+ try {
262
+ // Load settings from settings_data.json
263
+ const settingsPath = path.join(this.themePath, 'config', 'settings_data.json');
264
+ if (fs.existsSync(settingsPath)) {
265
+ const settingsData = fs.readFileSync(settingsPath, 'utf8');
266
+ context.settings = JSON.parse(settingsData);
267
+ }
268
+ } catch (error) {
269
+ console.warn('[CONTEXT] Failed to load settings:', error.message);
270
+ }
271
+
272
+ // Load data based on mode
273
+ if (this.mode === 'mock' && this.apiClient) {
274
+ // Use mock API (API client points to mock API via proxy)
275
+ try {
276
+ const storeInfo = await this.apiClient.getStoreInfo(true);
277
+ context.shop = storeInfo || {
278
+ name: 'My Store',
279
+ description: 'O2VEND Store',
280
+ domain: 'localhost:3000',
281
+ email: 'store@example.com'
282
+ };
283
+
284
+ // Get widgets for sections from mock API
285
+ const widgets = await this.widgetService.getWidgetsBySections();
286
+ context.widgets = widgets || {
287
+ hero: [],
288
+ products: [],
289
+ footer: [],
290
+ content: []
291
+ };
292
+ } catch (error) {
293
+ console.warn('[CONTEXT] Failed to load from mock API:', error.message);
294
+ // Fallback to default values
295
+ context.shop = {
296
+ name: 'My Store',
297
+ description: 'O2VEND Store',
298
+ domain: 'localhost:3000',
299
+ email: 'store@example.com'
300
+ };
301
+ context.widgets = {
302
+ hero: [],
303
+ products: [],
304
+ footer: [],
305
+ content: []
306
+ };
307
+ }
308
+ } else if (this.apiClient) {
309
+ // Use real API
310
+ try {
311
+ const storeInfo = await this.apiClient.getStoreInfo(true);
312
+ context.shop = storeInfo;
313
+ context.tenant = { id: this.apiClient.tenantId };
314
+
315
+ // Get widgets for sections
316
+ const widgets = await this.widgetService.getWidgetsBySections();
317
+ context.widgets = widgets;
318
+
319
+ // Load page-specific data
320
+ if (pageType === 'product' && extra.productHandle) {
321
+ // Load product data
322
+ // context.product = await this.loadProduct(extra.productHandle);
323
+ } else if (pageType === 'home') {
324
+ // Load products for homepage
325
+ // const productsResponse = await this.apiClient.getProducts({ limit: 12 });
326
+ // context.products = productsResponse.products || [];
327
+ }
328
+ } catch (error) {
329
+ console.warn('[CONTEXT] Failed to load from API:', error.message);
330
+ }
331
+ }
332
+
333
+ return context;
334
+ }
335
+
336
+ /**
337
+ * Start development server
338
+ */
339
+ async start() {
340
+ try {
341
+ // Setup services
342
+ await this.setupServices();
343
+
344
+ // Start mock API first if in mock mode
345
+ if (this.mode === 'mock') {
346
+ const MockApiServer = require('./mock-api-server');
347
+ this.mockApi = new MockApiServer({ port: this.mockApiPort });
348
+ await this.mockApi.start();
349
+
350
+ // Setup API proxy to mock API
351
+ const { createProxyMiddleware } = require('http-proxy-middleware');
352
+ this.app.use('/shopfront/api', createProxyMiddleware({
353
+ target: `http://localhost:${this.mockApiPort}`,
354
+ changeOrigin: true,
355
+ logLevel: 'silent'
356
+ }));
357
+ }
358
+
359
+ // Inject hot reload script into HTML responses
360
+ const { injectHotReloadScript } = require('./hot-reload');
361
+ const self = this;
362
+ this.app.use((req, res, next) => {
363
+ const originalSend = res.send;
364
+ res.send = function(data) {
365
+ if (typeof data === 'string' && (data.includes('<!DOCTYPE') || data.includes('<html'))) {
366
+ data = injectHotReloadScript(data, self.host, self.port);
367
+ }
368
+ return originalSend.call(res, data);
369
+ };
370
+ next();
371
+ });
372
+
373
+ // Create HTTP server and attach Socket.IO
374
+ const http = require('http');
375
+ this.server = http.createServer(this.app);
376
+
377
+ // Setup hot reload (WebSocket) - attach to HTTP server
378
+ this.io = new (require('socket.io').Server)(this.server, {
379
+ path: '/socket.io',
380
+ serveClient: true,
381
+ cors: {
382
+ origin: '*',
383
+ methods: ['GET', 'POST']
384
+ }
385
+ });
386
+
387
+ this.io.on('connection', (socket) => {
388
+ console.log(chalk.magenta('[Hot Reload] Client connected'));
389
+ socket.on('disconnect', () => {
390
+ console.log(chalk.magenta('[Hot Reload] Client disconnected'));
391
+ });
392
+ });
393
+
394
+ // Setup file watching
395
+ const { setupFileWatcher } = require('./file-watcher');
396
+ this.fileWatcher = setupFileWatcher(this.themePath, (changedFiles) => {
397
+ // Notify clients via WebSocket
398
+ if (this.io) {
399
+ this.io.emit('file-changed', changedFiles);
400
+ }
401
+
402
+ // Log changes
403
+ if (changedFiles.some(f => f.endsWith('.liquid'))) {
404
+ console.log(chalk.cyan('🔄 Liquid templates changed - reloading...'));
405
+ } else if (changedFiles.some(f => f.endsWith('.css'))) {
406
+ console.log(chalk.cyan('🎨 CSS changed - injecting...'));
407
+ } else if (changedFiles.some(f => f.endsWith('.js'))) {
408
+ console.log(chalk.cyan('📜 JavaScript changed - reloading...'));
409
+ }
410
+ });
411
+
412
+ // Start HTTP server
413
+ return new Promise((resolve, reject) => {
414
+ this.server.listen(this.port, this.host, (error) => {
415
+ if (error) {
416
+ reject(error);
417
+ return;
418
+ }
419
+
420
+ const url = `http://${this.host}:${this.port}`;
421
+ console.log(chalk.green(`\n✅ Server started successfully!`));
422
+ console.log(chalk.cyan(`📡 Server: ${url}`));
423
+ console.log(chalk.cyan(`🌐 Mode: ${this.mode === 'mock' ? 'Mock API' : 'Real API'}`));
424
+
425
+ if (this.mode === 'mock') {
426
+ console.log(chalk.cyan(`🎯 Mock API: http://${this.host}:${this.mockApiPort}`));
427
+ }
428
+
429
+ console.log(chalk.yellow(`\n📝 Watching for changes...`));
430
+ console.log(chalk.yellow(`💡 Press 'q' to quit\n`));
431
+
432
+ // Open browser if requested
433
+ if (this.openBrowser) {
434
+ const open = require('open');
435
+ open(url).catch(() => {
436
+ console.log(chalk.yellow(`💡 Open your browser: ${url}`));
437
+ });
438
+ }
439
+
440
+ resolve(this.server);
441
+ });
442
+ });
443
+ } catch (error) {
444
+ console.error(chalk.red('❌ Failed to start server:'), error);
445
+ throw error;
446
+ }
447
+ }
448
+
449
+ /**
450
+ * Stop development server
451
+ */
452
+ async stop() {
453
+ return new Promise((resolve) => {
454
+ // Stop file watcher
455
+ if (this.fileWatcher && typeof this.fileWatcher.close === 'function') {
456
+ this.fileWatcher.close();
457
+ }
458
+
459
+ // Close Socket.IO
460
+ if (this.io && typeof this.io.close === 'function') {
461
+ this.io.close();
462
+ }
463
+
464
+ // Stop mock API
465
+ if (this.mockApi && typeof this.mockApi.stop === 'function') {
466
+ this.mockApi.stop().catch(() => {});
467
+ }
468
+
469
+ // Close HTTP server
470
+ if (this.server) {
471
+ this.server.close(() => {
472
+ console.log(chalk.yellow('\n👋 Server stopped'));
473
+ resolve();
474
+ });
475
+ } else {
476
+ resolve();
477
+ }
478
+ });
479
+ }
480
+ }
481
+
482
+ module.exports = DevServer;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * File Watcher
3
+ * Watches theme files for changes and triggers reload
4
+ */
5
+
6
+ const chokidar = require('chokidar');
7
+ const path = require('path');
8
+
9
+ /**
10
+ * Setup file watcher for theme directory
11
+ * @param {string} themePath - Path to theme directory
12
+ * @param {Function} onChange - Callback when files change
13
+ * @returns {Object} Watcher instance
14
+ */
15
+ function setupFileWatcher(themePath, onChange) {
16
+ const watcher = chokidar.watch(themePath, {
17
+ ignored: [
18
+ /(^|[\/\\])\../, // Ignore dotfiles
19
+ '**/node_modules/**',
20
+ '**/.git/**',
21
+ '**/.o2vend-cache/**',
22
+ '**/*.log',
23
+ '**/.DS_Store'
24
+ ],
25
+ persistent: true,
26
+ ignoreInitial: true,
27
+ awaitWriteFinish: {
28
+ stabilityThreshold: 100,
29
+ pollInterval: 50
30
+ }
31
+ });
32
+
33
+ let changeTimeout = null;
34
+ const changedFiles = new Set();
35
+
36
+ watcher
37
+ .on('change', (filePath) => {
38
+ // Debounce multiple changes
39
+ changedFiles.add(filePath);
40
+
41
+ if (changeTimeout) {
42
+ clearTimeout(changeTimeout);
43
+ }
44
+
45
+ changeTimeout = setTimeout(() => {
46
+ const files = Array.from(changedFiles);
47
+ changedFiles.clear();
48
+
49
+ if (onChange) {
50
+ onChange(files);
51
+ }
52
+ }, 100);
53
+ })
54
+ .on('add', (filePath) => {
55
+ // New file added
56
+ changedFiles.add(filePath);
57
+
58
+ if (changeTimeout) {
59
+ clearTimeout(changeTimeout);
60
+ }
61
+
62
+ changeTimeout = setTimeout(() => {
63
+ const files = Array.from(changedFiles);
64
+ changedFiles.clear();
65
+
66
+ if (onChange) {
67
+ onChange(files);
68
+ }
69
+ }, 100);
70
+ })
71
+ .on('error', (error) => {
72
+ console.error('[FileWatcher] Error:', error);
73
+ });
74
+
75
+ return watcher;
76
+ }
77
+
78
+ module.exports = {
79
+ setupFileWatcher
80
+ };
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Hot Reload
3
+ * WebSocket server for live reloading in browser
4
+ */
5
+
6
+ const { Server } = require('socket.io');
7
+
8
+ /**
9
+ * Setup hot reload WebSocket server
10
+ * @returns {Server} Socket.IO server
11
+ */
12
+ function setupHotReload() {
13
+ const io = new Server({
14
+ path: '/socket.io',
15
+ serveClient: true,
16
+ cors: {
17
+ origin: '*',
18
+ methods: ['GET', 'POST']
19
+ }
20
+ });
21
+
22
+ // Handle connections (will be attached to HTTP server later)
23
+ io.on('connection', (socket) => {
24
+ // Client connected - will be used for hot reload
25
+ socket.on('disconnect', () => {
26
+ // Client disconnected
27
+ });
28
+ });
29
+
30
+ return io;
31
+ }
32
+
33
+ /**
34
+ * Inject hot reload script into HTML
35
+ * @param {string} html - HTML content
36
+ * @param {string} host - Server host
37
+ * @param {number} port - Server port
38
+ * @returns {string} HTML with hot reload script
39
+ */
40
+ function injectHotReloadScript(html, host = 'localhost', port = 3000) {
41
+ const script = `
42
+ <script src="/socket.io/socket.io.js"></script>
43
+ <script>
44
+ (function() {
45
+ if (typeof io !== 'undefined') {
46
+ const socket = io('http://${host}:${port}', {
47
+ path: '/socket.io',
48
+ transports: ['websocket', 'polling']
49
+ });
50
+
51
+ socket.on('connect', function() {
52
+ console.log('[Hot Reload] Connected');
53
+ });
54
+
55
+ socket.on('file-changed', function(files) {
56
+ console.log('[Hot Reload] Files changed:', files);
57
+
58
+ // Determine reload strategy based on file type
59
+ const hasCss = files.some(f => f.endsWith('.css'));
60
+ const hasLiquid = files.some(f => f.endsWith('.liquid'));
61
+ const hasJs = files.some(f => f.endsWith('.js'));
62
+
63
+ if (hasCss && !hasLiquid && !hasJs) {
64
+ // CSS only - inject without reload
65
+ console.log('[Hot Reload] Injecting CSS...');
66
+ files.filter(f => f.endsWith('.css')).forEach(function(file) {
67
+ const fileName = file.split(/[/\\\\]/).pop();
68
+ const links = document.querySelectorAll('link[rel="stylesheet"]');
69
+ links.forEach(function(link) {
70
+ if (link.href.includes(fileName)) {
71
+ const newLink = document.createElement('link');
72
+ newLink.rel = 'stylesheet';
73
+ newLink.href = link.href.split('?')[0] + '?v=' + Date.now();
74
+ link.parentNode.replaceChild(newLink, link);
75
+ }
76
+ });
77
+ });
78
+ } else {
79
+ // Full page reload
80
+ console.log('[Hot Reload] Reloading page...');
81
+ window.location.reload();
82
+ }
83
+ });
84
+
85
+ socket.on('disconnect', function() {
86
+ console.log('[Hot Reload] Disconnected');
87
+ });
88
+ }
89
+ })();
90
+ </script>
91
+ `;
92
+
93
+ // Inject before closing </body> tag
94
+ if (html.includes('</body>')) {
95
+ return html.replace('</body>', script + '</body>');
96
+ } else if (html.includes('</html>')) {
97
+ return html.replace('</html>', script + '</html>');
98
+ } else {
99
+ return html + script;
100
+ }
101
+ }
102
+
103
+ module.exports = {
104
+ setupHotReload,
105
+ injectHotReloadScript
106
+ };