@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.
- package/README.md +211 -0
- package/bin/o2vend +34 -0
- package/config/widget-map.json +45 -0
- package/package.json +64 -0
- package/src/commands/check.js +201 -0
- package/src/commands/generate.js +33 -0
- package/src/commands/init.js +302 -0
- package/src/commands/optimize.js +216 -0
- package/src/commands/package.js +208 -0
- package/src/commands/serve.js +105 -0
- package/src/commands/validate.js +191 -0
- package/src/lib/api-client.js +339 -0
- package/src/lib/dev-server.js +482 -0
- package/src/lib/file-watcher.js +80 -0
- package/src/lib/hot-reload.js +106 -0
- package/src/lib/liquid-engine.js +169 -0
- package/src/lib/liquid-filters.js +589 -0
- package/src/lib/mock-api-server.js +225 -0
- package/src/lib/mock-data.js +290 -0
- package/src/lib/widget-service.js +293 -0
|
@@ -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
|
+
};
|