zero-query 0.7.5 → 0.8.7

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 (65) hide show
  1. package/README.md +39 -30
  2. package/cli/commands/build.js +110 -1
  3. package/cli/commands/bundle.js +127 -50
  4. package/cli/commands/create.js +1 -1
  5. package/cli/commands/dev/devtools/index.js +56 -0
  6. package/cli/commands/dev/devtools/js/components.js +49 -0
  7. package/cli/commands/dev/devtools/js/core.js +409 -0
  8. package/cli/commands/dev/devtools/js/elements.js +413 -0
  9. package/cli/commands/dev/devtools/js/network.js +166 -0
  10. package/cli/commands/dev/devtools/js/performance.js +73 -0
  11. package/cli/commands/dev/devtools/js/router.js +105 -0
  12. package/cli/commands/dev/devtools/js/source.js +132 -0
  13. package/cli/commands/dev/devtools/js/stats.js +35 -0
  14. package/cli/commands/dev/devtools/js/tabs.js +79 -0
  15. package/cli/commands/dev/devtools/panel.html +95 -0
  16. package/cli/commands/dev/devtools/styles.css +244 -0
  17. package/cli/commands/dev/index.js +28 -3
  18. package/cli/commands/dev/logger.js +6 -1
  19. package/cli/commands/dev/overlay.js +377 -0
  20. package/cli/commands/dev/server.js +8 -0
  21. package/cli/commands/dev/watcher.js +26 -1
  22. package/cli/help.js +8 -5
  23. package/cli/scaffold/{scripts → app}/app.js +1 -1
  24. package/cli/scaffold/{scripts → app}/components/about.js +4 -4
  25. package/cli/scaffold/{scripts → app}/components/api-demo.js +1 -1
  26. package/cli/scaffold/app/components/home.js +137 -0
  27. package/cli/scaffold/{scripts → app}/routes.js +1 -1
  28. package/cli/scaffold/{scripts → app}/store.js +6 -6
  29. package/cli/scaffold/assets/.gitkeep +0 -0
  30. package/cli/scaffold/{styles/styles.css → global.css} +3 -2
  31. package/cli/scaffold/index.html +11 -11
  32. package/dist/zquery.dist.zip +0 -0
  33. package/dist/zquery.js +740 -226
  34. package/dist/zquery.min.js +2 -2
  35. package/index.d.ts +11 -11
  36. package/index.js +15 -10
  37. package/package.json +3 -2
  38. package/src/component.js +154 -139
  39. package/src/core.js +57 -11
  40. package/src/diff.js +256 -58
  41. package/src/expression.js +33 -3
  42. package/src/reactive.js +37 -5
  43. package/src/router.js +196 -7
  44. package/src/ssr.js +1 -1
  45. package/tests/component.test.js +582 -0
  46. package/tests/core.test.js +251 -0
  47. package/tests/diff.test.js +333 -2
  48. package/tests/expression.test.js +148 -0
  49. package/tests/http.test.js +108 -0
  50. package/tests/reactive.test.js +148 -0
  51. package/tests/router.test.js +317 -0
  52. package/tests/store.test.js +126 -0
  53. package/tests/utils.test.js +161 -2
  54. package/types/collection.d.ts +17 -2
  55. package/types/component.d.ts +10 -34
  56. package/types/misc.d.ts +13 -0
  57. package/types/router.d.ts +30 -1
  58. package/cli/commands/dev.old.js +0 -520
  59. package/cli/scaffold/scripts/components/home.js +0 -137
  60. /package/cli/scaffold/{scripts → app}/components/contacts/contacts.css +0 -0
  61. /package/cli/scaffold/{scripts → app}/components/contacts/contacts.html +0 -0
  62. /package/cli/scaffold/{scripts → app}/components/contacts/contacts.js +0 -0
  63. /package/cli/scaffold/{scripts → app}/components/counter.js +0 -0
  64. /package/cli/scaffold/{scripts → app}/components/not-found.js +0 -0
  65. /package/cli/scaffold/{scripts → app}/components/todos.js +0 -0
@@ -7,28 +7,6 @@
7
7
  import type { ReactiveProxy } from './reactive';
8
8
  import type { NavigationContext } from './router';
9
9
 
10
- /** Item in a `pages` config — either a string id or an `{ id, label }` object. */
11
- export type PageItem = string | { id: string; label?: string };
12
-
13
- /**
14
- * Declarative multi-page configuration for a component.
15
- *
16
- * Pages are **lazy-loaded**: only the active page is fetched on first render.
17
- * Remaining pages are prefetched in the background for instant navigation.
18
- */
19
- export interface PagesConfig {
20
- /** Directory containing the page HTML files (resolved relative to `base`). */
21
- dir?: string;
22
- /** Route parameter name to read (e.g. `'section'` for `/docs/:section`). */
23
- param?: string;
24
- /** Default page id when the param is absent. */
25
- default?: string;
26
- /** File extension appended to each page id (default `'.html'`). */
27
- ext?: string;
28
- /** List of page ids and/or `{ id, label }` objects. */
29
- items?: PageItem[];
30
- }
31
-
32
10
  /** The object passed to `$.component()` to define a component. */
33
11
  export interface ComponentDefinition {
34
12
  /**
@@ -58,12 +36,9 @@ export interface ComponentDefinition {
58
36
  */
59
37
  styleUrl?: string | string[];
60
38
 
61
- /** High-level multi-page configuration shorthand. */
62
- pages?: PagesConfig;
63
-
64
39
  /**
65
- * Override the base path for resolving relative `templateUrl`, `styleUrl`,
66
- * and `pages.dir` paths. Normally auto-detected from the calling file.
40
+ * Override the base path for resolving relative `templateUrl` and `styleUrl`
41
+ * paths. Normally auto-detected from the calling file.
67
42
  */
68
43
  base?: string;
69
44
 
@@ -121,15 +96,9 @@ export interface ComponentInstance {
121
96
  /** Map of `z-ref` name → DOM element. Populated after each render. */
122
97
  refs: Record<string, Element>;
123
98
 
124
- /** Keyed template map (when using multi-`templateUrl` or `pages`). */
99
+ /** Keyed template map (when using multi-`templateUrl`). */
125
100
  templates: Record<string, string>;
126
101
 
127
- /** Normalized page metadata (when using `pages` config). */
128
- pages: Array<{ id: string; label: string }>;
129
-
130
- /** Active page id derived from route param (when using `pages` config). */
131
- activePage: string;
132
-
133
102
  /**
134
103
  * Computed properties — lazy getters derived from state.
135
104
  * Defined via `computed` in the component definition.
@@ -187,6 +156,13 @@ export function destroy(target: string | Element): void;
187
156
  /** Returns an object of all registered component definitions (for debugging). */
188
157
  export function getRegistry(): Record<string, ComponentDefinition>;
189
158
 
159
+ /**
160
+ * Pre-load external templates and styles for a registered component.
161
+ * Useful for warming the cache before navigation to avoid blank flashes.
162
+ * @param name Registered component name.
163
+ */
164
+ export function prefetch(name: string): Promise<void>;
165
+
190
166
  /** Handle returned by `$.style()`. */
191
167
  export interface StyleHandle {
192
168
  /** Remove all injected `<link>` elements. */
package/types/misc.d.ts CHANGED
@@ -14,12 +14,25 @@
14
14
  * positions, video playback, and other live DOM state.
15
15
  *
16
16
  * Use `z-key="uniqueId"` attributes on list items for keyed reconciliation.
17
+ * Elements with `id`, `data-id`, or `data-key` attributes are auto-keyed.
17
18
  *
18
19
  * @param rootEl The live DOM container to patch.
19
20
  * @param newHTML The desired HTML string.
20
21
  */
21
22
  export function morph(rootEl: Element, newHTML: string): void;
22
23
 
24
+ /**
25
+ * Morph a single element in place — diffs attributes and children
26
+ * without replacing the node reference. If the tag name matches, the
27
+ * element is patched in place (preserving identity). If the tag differs,
28
+ * the element is replaced.
29
+ *
30
+ * @param oldEl The live DOM element to patch.
31
+ * @param newHTML HTML string for the replacement element.
32
+ * @returns The resulting element (same ref if morphed, new if replaced).
33
+ */
34
+ export function morphElement(oldEl: Element, newHTML: string): Element;
35
+
23
36
  // ---------------------------------------------------------------------------
24
37
  // Safe Expression Evaluator
25
38
  // ---------------------------------------------------------------------------
package/types/router.d.ts CHANGED
@@ -59,11 +59,13 @@ export interface RouterInstance {
59
59
  /**
60
60
  * Push a new state and resolve the route.
61
61
  * Supports `:param` interpolation when `options.params` is provided.
62
+ * Same-path navigation is deduplicated (skipped unless `options.force` is true).
63
+ * Hash-only changes on the same route use `replaceState` to avoid extra history entries.
62
64
  * @example
63
65
  * router.navigate('/user/:id', { params: { id: 42 } }); // navigates to /user/42
64
66
  * router.navigate('/dashboard', { state: { from: 'login' } });
65
67
  */
66
- navigate(path: string, options?: { params?: Record<string, string | number>; state?: any }): RouterInstance;
68
+ navigate(path: string, options?: { params?: Record<string, string | number>; state?: any; force?: boolean }): RouterInstance;
67
69
  /**
68
70
  * Replace the current state (no new history entry).
69
71
  * Supports `:param` interpolation when `options.params` is provided.
@@ -107,6 +109,33 @@ export interface RouterInstance {
107
109
  fn: (to: NavigationContext, from: NavigationContext | null) => void,
108
110
  ): () => void;
109
111
 
112
+ /**
113
+ * Push a lightweight history entry for in-component UI state (modal, tab, panel).
114
+ * The URL does NOT change — only a history entry is added so the back button
115
+ * can undo the UI change before navigating away from the route.
116
+ * @param key — identifier for the substate (e.g. 'modal', 'tab')
117
+ * @param data — arbitrary serializable state
118
+ * @example
119
+ * router.pushSubstate('modal', { id: 'confirm-delete' });
120
+ */
121
+ pushSubstate(key: string, data?: any): RouterInstance;
122
+
123
+ /**
124
+ * Register a listener for substate pops (back button on a substate entry).
125
+ * The callback receives `(key, data, action)` and should return `true` if it
126
+ * handled the pop (prevents route resolution). If no listener returns `true`,
127
+ * normal route resolution proceeds.
128
+ * @returns An unsubscribe function.
129
+ * @example
130
+ * const unsub = router.onSubstate((key, data, action) => {
131
+ * if (action === 'reset') { resetDefaults(); return true; }
132
+ * if (key === 'modal') { closeModal(); return true; }
133
+ * });
134
+ */
135
+ onSubstate(
136
+ fn: (key: string | null, data: any, action: 'pop' | 'resolve' | 'reset') => boolean | void,
137
+ ): () => void;
138
+
110
139
  /** Teardown the router and mounted component. */
111
140
  destroy(): void;
112
141
 
@@ -1,520 +0,0 @@
1
- /**
2
- * cli/commands/dev.js — development server with live-reload
3
- *
4
- * Starts a zero-http server that serves the project root, injects an
5
- * SSE live-reload snippet, auto-resolves zquery.min.js, and watches
6
- * for file changes (CSS hot-swap, everything else full reload).
7
- *
8
- * Features:
9
- * - Pre-validates JS files on save and reports syntax errors
10
- * - Broadcasts errors to the browser via SSE with code frames
11
- * - Full-screen error overlay in the browser (runtime + syntax)
12
- */
13
-
14
- 'use strict';
15
-
16
- const fs = require('fs');
17
- const path = require('path');
18
- const vm = require('vm');
19
-
20
- const { args, flag, option } = require('../args');
21
-
22
- // ---------------------------------------------------------------------------
23
- // Syntax validation helpers
24
- // ---------------------------------------------------------------------------
25
-
26
- /**
27
- * Generate a code frame string for an error at a given line/column.
28
- * Shows ~4 lines of context around the error with a caret pointer.
29
- */
30
- function generateCodeFrame(source, line, column) {
31
- const lines = source.split('\n');
32
- const start = Math.max(0, line - 4);
33
- const end = Math.min(lines.length, line + 3);
34
- const pad = String(end).length;
35
- const frame = [];
36
-
37
- for (let i = start; i < end; i++) {
38
- const lineNum = String(i + 1).padStart(pad);
39
- const marker = i === line - 1 ? '>' : ' ';
40
- frame.push(`${marker} ${lineNum} | ${lines[i]}`);
41
- if (i === line - 1 && column > 0) {
42
- frame.push(` ${' '.repeat(pad)} | ${' '.repeat(column - 1)}^`);
43
- }
44
- }
45
- return frame.join('\n');
46
- }
47
-
48
- /**
49
- * Validate a JavaScript file for syntax errors using Node's VM module.
50
- * Returns null if valid, or an error descriptor object.
51
- */
52
- function validateJS(filePath, relPath) {
53
- let source;
54
- try { source = fs.readFileSync(filePath, 'utf-8'); } catch { return null; }
55
-
56
- // Strip import/export so the VM can parse it as a script.
57
- // Process line-by-line to guarantee line numbers stay accurate.
58
- const normalized = source.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
59
- const stripped = normalized.split('\n').map(line => {
60
- if (/^\s*import\s+.*from\s+['"]/.test(line)) return ' '.repeat(line.length);
61
- if (/^\s*import\s+['"]/.test(line)) return ' '.repeat(line.length);
62
- if (/^\s*export\s*\{/.test(line)) return ' '.repeat(line.length);
63
- line = line.replace(/^(\s*)export\s+default\s+/, '$1');
64
- line = line.replace(/^(\s*)export\s+(const|let|var|function|class|async\s+function)\s/, '$1$2 ');
65
- // import.meta is module-only syntax; replace with a harmless expression
66
- line = line.replace(/import\.meta\.url/g, "'__meta__'");
67
- line = line.replace(/import\.meta/g, '({})');
68
- return line;
69
- }).join('\n');
70
-
71
- try {
72
- new vm.Script(stripped, { filename: relPath });
73
- return null;
74
- } catch (err) {
75
- const line = err.stack ? parseInt((err.stack.match(/:(\d+)/) || [])[1]) || 0 : 0;
76
- const col = err.stack ? parseInt((err.stack.match(/:(\d+):(\d+)/) || [])[2]) || 0 : 0;
77
- const frame = line > 0 ? generateCodeFrame(source, line, col) : '';
78
- return {
79
- type: err.constructor.name || 'SyntaxError',
80
- message: err.message,
81
- file: relPath,
82
- line,
83
- column: col,
84
- frame,
85
- };
86
- }
87
- }
88
-
89
- // ---------------------------------------------------------------------------
90
- // SSE live-reload + error overlay client script injected into served HTML
91
- // ---------------------------------------------------------------------------
92
-
93
- const LIVE_RELOAD_SNIPPET = `<script>
94
- (function(){
95
- // -----------------------------------------------------------------------
96
- // Error Overlay
97
- // -----------------------------------------------------------------------
98
- var overlayEl = null;
99
-
100
- var OVERLAY_STYLE =
101
- 'position:fixed;top:0;left:0;width:100%;height:100%;' +
102
- 'background:rgba(0,0,0,0.92);color:#fff;z-index:2147483647;' +
103
- 'font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;' +
104
- 'font-size:13px;overflow-y:auto;padding:0;margin:0;box-sizing:border-box;';
105
-
106
- var HEADER_STYLE =
107
- 'padding:20px 24px 12px;border-bottom:1px solid rgba(255,255,255,0.1);' +
108
- 'display:flex;align-items:flex-start;justify-content:space-between;';
109
-
110
- var TYPE_STYLE =
111
- 'display:inline-block;padding:3px 8px;border-radius:4px;font-size:11px;' +
112
- 'font-weight:700;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:8px;';
113
-
114
- function createOverlay(data) {
115
- removeOverlay();
116
- var wrap = document.createElement('div');
117
- wrap.id = '__zq_error_overlay';
118
- wrap.setAttribute('style', OVERLAY_STYLE);
119
- // keyboard focus for esc
120
- wrap.setAttribute('tabindex', '-1');
121
-
122
- var isSyntax = data.type && /syntax|parse/i.test(data.type);
123
- var badgeColor = isSyntax ? '#e74c3c' : '#e67e22';
124
-
125
- var html = '';
126
- // Header row
127
- html += '<div style="' + HEADER_STYLE + '">';
128
- html += '<div>';
129
- html += '<span style="' + TYPE_STYLE + 'background:' + badgeColor + ';">' + esc(data.type || 'Error') + '</span>';
130
- html += '<div style="font-size:18px;font-weight:600;line-height:1.4;color:#ff6b6b;margin-top:4px;">';
131
- html += esc(data.message || 'Unknown error');
132
- html += '</div>';
133
- html += '</div>';
134
- // Close button
135
- html += '<button id="__zq_close" style="' +
136
- 'background:none;border:1px solid rgba(255,255,255,0.2);color:#999;' +
137
- 'font-size:20px;cursor:pointer;border-radius:6px;width:32px;height:32px;' +
138
- 'display:flex;align-items:center;justify-content:center;flex-shrink:0;' +
139
- 'margin-left:16px;transition:all 0.15s;"' +
140
- ' onmouseover="this.style.color=\\'#fff\\';this.style.borderColor=\\'rgba(255,255,255,0.5)\\'"' +
141
- ' onmouseout="this.style.color=\\'#999\\';this.style.borderColor=\\'rgba(255,255,255,0.2)\\'"' +
142
- '>&times;</button>';
143
- html += '</div>';
144
-
145
- // File location
146
- if (data.file) {
147
- html += '<div style="padding:10px 24px;color:#8be9fd;font-size:13px;">';
148
- html += '<span style="color:#888;">File: </span>' + esc(data.file);
149
- if (data.line) html += '<span style="color:#888;">:</span>' + data.line;
150
- if (data.column) html += '<span style="color:#888;">:</span>' + data.column;
151
- html += '</div>';
152
- }
153
-
154
- // Code frame
155
- if (data.frame) {
156
- html += '<pre style="' +
157
- 'margin:0;padding:16px 24px;background:rgba(255,255,255,0.04);' +
158
- 'border-top:1px solid rgba(255,255,255,0.06);' +
159
- 'border-bottom:1px solid rgba(255,255,255,0.06);' +
160
- 'overflow-x:auto;line-height:1.6;font-size:13px;' +
161
- '">';
162
- var frameLines = data.frame.split('\\n');
163
- for (var i = 0; i < frameLines.length; i++) {
164
- var fl = frameLines[i];
165
- if (fl.charAt(0) === '>') {
166
- html += '<span style="color:#ff6b6b;font-weight:600;">' + esc(fl) + '</span>\\n';
167
- } else if (fl.indexOf('^') !== -1 && fl.trim().replace(/[\\s|^]/g, '') === '') {
168
- html += '<span style="color:#e74c3c;font-weight:700;">' + esc(fl) + '</span>\\n';
169
- } else {
170
- html += '<span style="color:#999;">' + esc(fl) + '</span>\\n';
171
- }
172
- }
173
- html += '</pre>';
174
- }
175
-
176
- // Stack trace
177
- if (data.stack) {
178
- html += '<div style="padding:16px 24px;">';
179
- html += '<div style="color:#888;font-size:11px;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:8px;">Stack Trace</div>';
180
- html += '<pre style="margin:0;color:#bbb;font-size:12px;line-height:1.7;white-space:pre-wrap;word-break:break-word;">';
181
- html += esc(data.stack);
182
- html += '</pre></div>';
183
- }
184
-
185
- // Tip
186
- html += '<div style="padding:16px 24px;color:#555;font-size:11px;border-top:1px solid rgba(255,255,255,0.06);">';
187
- html += 'Fix the error and save — the overlay will clear automatically. Press <kbd style="' +
188
- 'background:rgba(255,255,255,0.1);padding:1px 6px;border-radius:3px;font-size:11px;' +
189
- '">Esc</kbd> to dismiss.';
190
- html += '</div>';
191
-
192
- wrap.innerHTML = html;
193
- document.body.appendChild(wrap);
194
- overlayEl = wrap;
195
-
196
- // Close button handler
197
- var closeBtn = document.getElementById('__zq_close');
198
- if (closeBtn) closeBtn.addEventListener('click', removeOverlay);
199
- wrap.addEventListener('keydown', function(e) {
200
- if (e.key === 'Escape') removeOverlay();
201
- });
202
- wrap.focus();
203
- }
204
-
205
- function removeOverlay() {
206
- if (overlayEl && overlayEl.parentNode) {
207
- overlayEl.parentNode.removeChild(overlayEl);
208
- }
209
- overlayEl = null;
210
- }
211
-
212
- function esc(s) {
213
- var d = document.createElement('div');
214
- d.appendChild(document.createTextNode(s));
215
- return d.innerHTML;
216
- }
217
-
218
- // -----------------------------------------------------------------------
219
- // Runtime error handlers
220
- // -----------------------------------------------------------------------
221
- window.addEventListener('error', function(e) {
222
- if (!e.filename) return;
223
- var data = {
224
- type: (e.error && e.error.constructor && e.error.constructor.name) || 'Error',
225
- message: e.message || String(e.error),
226
- file: e.filename.replace(location.origin, ''),
227
- line: e.lineno || 0,
228
- column: e.colno || 0,
229
- stack: e.error && e.error.stack ? cleanStack(e.error.stack) : ''
230
- };
231
- createOverlay(data);
232
- logToConsole(data);
233
- });
234
-
235
- window.addEventListener('unhandledrejection', function(e) {
236
- var err = e.reason;
237
- var data = {
238
- type: 'Unhandled Promise Rejection',
239
- message: err && err.message ? err.message : String(err),
240
- stack: err && err.stack ? cleanStack(err.stack) : ''
241
- };
242
- createOverlay(data);
243
- logToConsole(data);
244
- });
245
-
246
- function cleanStack(stack) {
247
- return stack.split('\\n')
248
- .filter(function(l) {
249
- return l.indexOf('__zq_') === -1 && l.indexOf('EventSource') === -1;
250
- })
251
- .map(function(l) {
252
- return l.replace(location.origin, '');
253
- })
254
- .join('\\n');
255
- }
256
-
257
- function logToConsole(data) {
258
- var msg = '\\n%c zQuery DevError %c ' + data.type + ': ' + data.message;
259
- if (data.file) msg += '\\n at ' + data.file + (data.line ? ':' + data.line : '') + (data.column ? ':' + data.column : '');
260
- console.error(msg, 'background:#e74c3c;color:#fff;padding:2px 6px;border-radius:3px;font-weight:700;', 'color:inherit;');
261
- if (data.frame) console.error(data.frame);
262
- }
263
-
264
- // -----------------------------------------------------------------------
265
- // SSE connection (live-reload + error events)
266
- // -----------------------------------------------------------------------
267
- var es, timer;
268
- function connect(){
269
- es = new EventSource('/__zq_reload');
270
-
271
- es.addEventListener('reload', function(){
272
- removeOverlay();
273
- location.reload();
274
- });
275
-
276
- es.addEventListener('css', function(e){
277
- var sheets = document.querySelectorAll('link[rel="stylesheet"]');
278
- sheets.forEach(function(l){
279
- var href = l.getAttribute('href');
280
- if(!href) return;
281
- var sep = href.indexOf('?') >= 0 ? '&' : '?';
282
- l.setAttribute('href', href.replace(/[?&]_zqr=\\\\d+/, '') + sep + '_zqr=' + Date.now());
283
- });
284
- });
285
-
286
- es.addEventListener('error:syntax', function(e){
287
- try {
288
- var data = JSON.parse(e.data);
289
- createOverlay(data);
290
- logToConsole(data);
291
- } catch(_){}
292
- });
293
-
294
- es.addEventListener('error:clear', function(){
295
- removeOverlay();
296
- });
297
-
298
- es.onerror = function(){
299
- es.close();
300
- clearTimeout(timer);
301
- timer = setTimeout(connect, 2000);
302
- };
303
- }
304
- connect();
305
- })();
306
- </script>`;
307
-
308
- // ---------------------------------------------------------------------------
309
- // devServer
310
- // ---------------------------------------------------------------------------
311
-
312
- function devServer() {
313
- let zeroHttp;
314
- try {
315
- zeroHttp = require('zero-http');
316
- } catch (_) {
317
- console.error(`\n ✗ zero-http is required for the dev server.`);
318
- console.error(` Install it: npm install zero-http --save-dev\n`);
319
- process.exit(1);
320
- }
321
-
322
- const { createApp, static: serveStatic } = zeroHttp;
323
-
324
- // Custom HTML entry file (default: index.html)
325
- const htmlEntry = option('index', 'i', 'index.html');
326
-
327
- // Determine the project root to serve
328
- let root = null;
329
- for (let i = 1; i < args.length; i++) {
330
- if (!args[i].startsWith('-') && args[i - 1] !== '-p' && args[i - 1] !== '--port' && args[i - 1] !== '--index') {
331
- root = path.resolve(process.cwd(), args[i]);
332
- break;
333
- }
334
- }
335
- if (!root) {
336
- const candidates = [
337
- process.cwd(),
338
- path.join(process.cwd(), 'public'),
339
- path.join(process.cwd(), 'src'),
340
- ];
341
- for (const c of candidates) {
342
- if (fs.existsSync(path.join(c, htmlEntry))) { root = c; break; }
343
- }
344
- if (!root) root = process.cwd();
345
- }
346
-
347
- const PORT = parseInt(option('port', 'p', '3100'));
348
-
349
- // SSE clients
350
- const sseClients = new Set();
351
-
352
- const app = createApp();
353
-
354
- // SSE endpoint
355
- app.get('/__zq_reload', (req, res) => {
356
- const sse = res.sse({ keepAlive: 30000, keepAliveComment: 'ping' });
357
- sseClients.add(sse);
358
- sse.on('close', () => sseClients.delete(sse));
359
- });
360
-
361
- // Auto-resolve zquery.min.js
362
- // __dirname is cli/commands/, package root is two levels up
363
- const pkgRoot = path.resolve(__dirname, '..', '..');
364
- const noIntercept = flag('no-intercept');
365
-
366
- app.use((req, res, next) => {
367
- if (noIntercept) return next();
368
- const basename = path.basename(req.url.split('?')[0]).toLowerCase();
369
- if (basename !== 'zquery.min.js') return next();
370
-
371
- const candidates = [
372
- path.join(pkgRoot, 'dist', 'zquery.min.js'),
373
- path.join(root, 'node_modules', 'zero-query', 'dist', 'zquery.min.js'),
374
- ];
375
- for (const p of candidates) {
376
- if (fs.existsSync(p)) {
377
- res.set('Content-Type', 'application/javascript; charset=utf-8');
378
- res.set('Cache-Control', 'no-cache');
379
- res.send(fs.readFileSync(p, 'utf-8'));
380
- return;
381
- }
382
- }
383
- next();
384
- });
385
-
386
- // Static file serving
387
- app.use(serveStatic(root, { index: false, dotfiles: 'ignore' }));
388
-
389
- // SPA fallback — inject live-reload
390
- app.get('*', (req, res) => {
391
- if (path.extname(req.url) && path.extname(req.url) !== '.html') {
392
- res.status(404).send('Not Found');
393
- return;
394
- }
395
- const indexPath = path.join(root, htmlEntry);
396
- if (!fs.existsSync(indexPath)) {
397
- res.status(404).send(`${htmlEntry} not found`);
398
- return;
399
- }
400
- let html = fs.readFileSync(indexPath, 'utf-8');
401
- if (html.includes('</body>')) {
402
- html = html.replace('</body>', LIVE_RELOAD_SNIPPET + '\n</body>');
403
- } else {
404
- html += LIVE_RELOAD_SNIPPET;
405
- }
406
- res.html(html);
407
- });
408
-
409
- // Broadcast helper
410
- function broadcast(eventType, data) {
411
- for (const sse of sseClients) {
412
- try { sse.event(eventType, data || ''); } catch (_) { sseClients.delete(sse); }
413
- }
414
- }
415
-
416
- // File watcher
417
- const IGNORE_DIRS = new Set(['node_modules', '.git', 'dist', '.cache']);
418
- let debounceTimer;
419
-
420
- function shouldWatch(filename) {
421
- if (!filename) return false;
422
- if (filename.startsWith('.')) return false;
423
- return true;
424
- }
425
-
426
- function isIgnored(filepath) {
427
- const parts = filepath.split(path.sep);
428
- return parts.some(p => IGNORE_DIRS.has(p));
429
- }
430
-
431
- function collectWatchDirs(dir) {
432
- const dirs = [dir];
433
- try {
434
- const entries = fs.readdirSync(dir, { withFileTypes: true });
435
- for (const entry of entries) {
436
- if (!entry.isDirectory()) continue;
437
- if (IGNORE_DIRS.has(entry.name)) continue;
438
- const sub = path.join(dir, entry.name);
439
- dirs.push(...collectWatchDirs(sub));
440
- }
441
- } catch (_) {}
442
- return dirs;
443
- }
444
-
445
- const watchDirs = collectWatchDirs(root);
446
- const watchers = [];
447
-
448
- // Track current error state to know when to clear
449
- let currentError = null;
450
-
451
- for (const dir of watchDirs) {
452
- try {
453
- const watcher = fs.watch(dir, (eventType, filename) => {
454
- if (!shouldWatch(filename)) return;
455
- const fullPath = path.join(dir, filename || '');
456
- if (isIgnored(fullPath)) return;
457
-
458
- clearTimeout(debounceTimer);
459
- debounceTimer = setTimeout(() => {
460
- const rel = path.relative(root, fullPath).replace(/\\/g, '/');
461
- const ext = path.extname(filename).toLowerCase();
462
- const now = new Date().toLocaleTimeString();
463
-
464
- if (ext === '.css') {
465
- console.log(` ${now} \x1b[35m css \x1b[0m ${rel}`);
466
- broadcast('css', rel);
467
- return;
468
- }
469
-
470
- // Validate JS files for syntax errors before triggering reload
471
- if (ext === '.js') {
472
- const err = validateJS(fullPath, rel);
473
- if (err) {
474
- currentError = rel;
475
- console.log(` ${now} \x1b[31m error \x1b[0m ${rel}`);
476
- console.log(` \x1b[31m${err.type}: ${err.message}\x1b[0m`);
477
- if (err.line) console.log(` \x1b[2mat line ${err.line}${err.column ? ':' + err.column : ''}\x1b[0m`);
478
- broadcast('error:syntax', JSON.stringify(err));
479
- return;
480
- }
481
- // File was fixed — clear previous error if it was in this file
482
- if (currentError === rel) {
483
- currentError = null;
484
- broadcast('error:clear', '');
485
- }
486
- }
487
-
488
- console.log(` ${now} \x1b[36m reload \x1b[0m ${rel}`);
489
- broadcast('reload', rel);
490
- }, 100);
491
- });
492
- watchers.push(watcher);
493
- } catch (_) {}
494
- }
495
-
496
- app.listen(PORT, () => {
497
- console.log(`\n \x1b[1mzQuery Dev Server\x1b[0m`);
498
- console.log(` \x1b[2m${'-'.repeat(40)}\x1b[0m`);
499
- console.log(` Local: \x1b[36mhttp://localhost:${PORT}/\x1b[0m`);
500
- console.log(` Root: ${path.relative(process.cwd(), root) || '.'}`);
501
- if (htmlEntry !== 'index.html') console.log(` HTML: \x1b[36m${htmlEntry}\x1b[0m`);
502
- console.log(` Live Reload: \x1b[32menabled\x1b[0m (SSE)`);
503
- console.log(` Overlay: \x1b[32menabled\x1b[0m (syntax + runtime errors)`);
504
- if (noIntercept) console.log(` Intercept: \x1b[33mdisabled\x1b[0m (--no-intercept)`);
505
- console.log(` Watching: all files in ${watchDirs.length} director${watchDirs.length === 1 ? 'y' : 'ies'}`);
506
- console.log(` \x1b[2m${'-'.repeat(40)}\x1b[0m`);
507
- console.log(` Press Ctrl+C to stop\n`);
508
- });
509
-
510
- // Graceful shutdown
511
- process.on('SIGINT', () => {
512
- console.log('\n Shutting down...');
513
- watchers.forEach(w => w.close());
514
- for (const sse of sseClients) { try { sse.close(); } catch (_) {} }
515
- app.close(() => process.exit(0));
516
- setTimeout(() => process.exit(0), 1000);
517
- });
518
- }
519
-
520
- module.exports = devServer;