zero-query 0.6.3 → 0.7.5

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 (41) hide show
  1. package/README.md +6 -6
  2. package/cli/commands/build.js +3 -3
  3. package/cli/commands/bundle.js +286 -8
  4. package/cli/commands/dev/index.js +2 -2
  5. package/cli/commands/dev/overlay.js +51 -2
  6. package/cli/commands/dev/server.js +34 -5
  7. package/cli/commands/dev/watcher.js +33 -0
  8. package/cli/scaffold/index.html +1 -0
  9. package/cli/scaffold/scripts/app.js +15 -22
  10. package/cli/scaffold/scripts/components/contacts/contacts.css +0 -7
  11. package/cli/scaffold/scripts/components/contacts/contacts.html +3 -3
  12. package/cli/scaffold/styles/styles.css +1 -0
  13. package/cli/utils.js +111 -6
  14. package/dist/zquery.dist.zip +0 -0
  15. package/dist/zquery.js +379 -27
  16. package/dist/zquery.min.js +3 -16
  17. package/index.d.ts +127 -1290
  18. package/package.json +5 -5
  19. package/src/component.js +11 -1
  20. package/src/core.js +305 -10
  21. package/src/router.js +49 -2
  22. package/tests/component.test.js +304 -0
  23. package/tests/core.test.js +726 -0
  24. package/tests/diff.test.js +194 -0
  25. package/tests/errors.test.js +162 -0
  26. package/tests/expression.test.js +334 -0
  27. package/tests/http.test.js +181 -0
  28. package/tests/reactive.test.js +191 -0
  29. package/tests/router.test.js +332 -0
  30. package/tests/store.test.js +253 -0
  31. package/tests/utils.test.js +353 -0
  32. package/types/collection.d.ts +368 -0
  33. package/types/component.d.ts +210 -0
  34. package/types/errors.d.ts +103 -0
  35. package/types/http.d.ts +81 -0
  36. package/types/misc.d.ts +166 -0
  37. package/types/reactive.d.ts +76 -0
  38. package/types/router.d.ts +132 -0
  39. package/types/ssr.d.ts +49 -0
  40. package/types/store.d.ts +107 -0
  41. package/types/utils.d.ts +142 -0
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src=".github/images/logo.svg" alt="zQuery logo" width="300" height="300">
2
+ <img src=".github/images/logo-animated.svg" alt="zQuery logo" width="300" height="300">
3
3
  </p>
4
4
 
5
5
  <h1 align="center">zQuery</h1>
@@ -15,18 +15,18 @@
15
15
 
16
16
  </p>
17
17
 
18
- > **Lightweight, zero-dependency frontend library that combines jQuery-style DOM manipulation with a modern reactive component system, SPA router, global state management, HTTP client, and utility toolkit — all in a single ~84 KB minified browser bundle. Works out of the box with ES modules. An optional CLI bundler is available for single-file production builds.**
18
+ > **Lightweight, zero-dependency frontend library that combines jQuery-style DOM manipulation with a modern reactive component system, SPA router, global state management, HTTP client, and utility toolkit — all in a single ~80 KB minified browser bundle. Works out of the box with ES modules. An optional CLI bundler is available for single-file production builds.**
19
19
 
20
20
  ## Features
21
21
 
22
22
  | Module | Highlights |
23
23
  | --- | --- |
24
- | **Core `$()`** | jQuery-like chainable selectors, traversal, DOM manipulation, events, animation |
24
+ | **Router** | History & hash mode, route params (`:id`), guards, lazy loading, `z-link` navigation, `z-to-top` scroll modifier (`instant`/`smooth`) |
25
25
  | **Components** | Reactive state, template literals, `@event` delegation (8 modifiers), `z-model` two-way binding, computed properties, watch callbacks, slot-based content projection, directives (`z-if`/`z-else-if`/`z-else`, `z-for`, `z-show`, `z-bind`/`:attr`, `z-class`, `z-style`, `z-text`, `z-html`, `z-ref`, `z-cloak`, `z-pre`, `z-key`), DOM morphing engine (no innerHTML rebuild), CSP-safe expression evaluation, scoped styles, external templates (`templateUrl` / `styleUrl`), lifecycle hooks, auto-injected base styles |
26
- | **Router** | History & hash mode, route params (`:id`), guards, lazy loading, `z-link` navigation |
27
26
  | **Store** | Reactive global state, named actions, computed getters, middleware, subscriptions |
28
27
  | **HTTP** | Fetch wrapper with auto-JSON, interceptors, timeout/abort, base URL |
29
28
  | **Reactive** | Deep proxy reactivity, Signals, computed values, effects |
29
+ | **Selectors & DOM** | jQuery-like chainable selectors, traversal, DOM manipulation, events, animation |
30
30
  | **Utils** | debounce, throttle, pipe, once, sleep, escapeHtml, uuid, deepClone, deepMerge, storage/session wrappers, event bus |
31
31
 
32
32
  ---
@@ -69,7 +69,7 @@ If you prefer **zero tooling**, download `dist/zQuery.min.js` from the [GitHub r
69
69
  git clone https://github.com/tonywied17/zero-query.git
70
70
  cd zero-query
71
71
  npx zquery build
72
- # → dist/zQuery.min.js (~84 KB)
72
+ # → dist/zQuery.min.js (~80 KB)
73
73
  ```
74
74
 
75
75
  ### Include in HTML
@@ -278,7 +278,7 @@ For full method signatures, options, and examples, see **[API.md](API.md)**.
278
278
 
279
279
  ## Editor Support
280
280
 
281
- The official **[zQuery for VS Code](https://marketplace.visualstudio.com/items?itemName=zQuery.zquery-vs-code)** extension provides autocomplete, hover docs, HTML directive support, and 140+ code snippets for every API method and directive. Install it from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=zQuery.zquery-vs-code) or search **"zQuery for VS Code"** in Extensions.
281
+ The official **[zQuery for VS Code](https://marketplace.visualstudio.com/items?itemName=zQuery.zquery-vs-code)** extension provides autocomplete, hover docs, HTML directive support, and 185+ code snippets for every API method and directive. Install it from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=zQuery.zquery-vs-code) or search **"zQuery for VS Code"** in Extensions.
282
282
 
283
283
  ---
284
284
 
@@ -34,7 +34,7 @@ function buildLibrary() {
34
34
  code = code.replace(/^import\s+[\s\S]*?from\s+['"].*?['"];?\s*$/gm, '');
35
35
  code = code.replace(/^export\s+(default\s+)?/gm, '');
36
36
  code = code.replace(/^export\s*\{[\s\S]*?\};\s*$/gm, '');
37
- return `// --- ${file} ${''.repeat(60 - file.length)}\n${code.trim()}`;
37
+ return `// --- ${file} ${'-'.repeat(60 - file.length)}\n${code.trim()}`;
38
38
  });
39
39
 
40
40
  let indexCode = fs.readFileSync(path.join(process.cwd(), 'index.js'), 'utf-8');
@@ -42,9 +42,9 @@ function buildLibrary() {
42
42
  indexCode = indexCode.replace(/^export\s*\{[\s\S]*?\};\s*$/gm, '');
43
43
  indexCode = indexCode.replace(/^export\s+(default\s+)?/gm, '');
44
44
 
45
- const banner = `/**\n * zQuery (zeroQuery) v${VERSION}\n * Lightweight Frontend Library\n * https://github.com/tonywied17/zero-query\n * (c) ${new Date().getFullYear()} Anthony Wiedman MIT License\n */`;
45
+ const banner = `/**\n * zQuery (zeroQuery) v${VERSION}\n * Lightweight Frontend Library\n * https://github.com/tonywied17/zero-query\n * (c) ${new Date().getFullYear()} Anthony Wiedman - MIT License\n */`;
46
46
 
47
- const bundle = `${banner}\n(function(global) {\n 'use strict';\n\n${parts.join('\n\n')}\n\n// --- index.js (assembly) ${''.repeat(42)}\n${indexCode.trim().replace("'__VERSION__'", `'${VERSION}'`)}\n\n})(typeof window !== 'undefined' ? window : globalThis);\n`;
47
+ const bundle = `${banner}\n(function(global) {\n 'use strict';\n\n${parts.join('\n\n')}\n\n// --- index.js (assembly) ${'-'.repeat(42)}\n${indexCode.trim().replace("'__VERSION__'", `'${VERSION}'`)}\n\n})(typeof window !== 'undefined' ? window : globalThis);\n`;
48
48
 
49
49
  fs.writeFileSync(OUT_FILE, bundle, 'utf-8');
50
50
  fs.writeFileSync(MIN_FILE, minify(bundle, banner), 'utf-8');
@@ -24,6 +24,8 @@ const buildLibrary = require('./build');
24
24
  /** Resolve an import specifier relative to the importing file. */
25
25
  function resolveImport(specifier, fromFile) {
26
26
  if (!specifier.startsWith('.') && !specifier.startsWith('/')) return null;
27
+ // Skip non-JS assets (CSS, images, etc.) referenced in code examples
28
+ if (/\.(?:css|json|svg|png|jpg|gif|woff2?|ttf|eot)$/i.test(specifier)) return null;
27
29
  let resolved = path.resolve(path.dirname(fromFile), specifier);
28
30
  if (!path.extname(resolved) && fs.existsSync(resolved + '.js')) {
29
31
  resolved += '.js';
@@ -112,6 +114,243 @@ function rewriteResourceUrls(code, filePath, projectRoot) {
112
114
  );
113
115
  }
114
116
 
117
+ /**
118
+ * Minify HTML for inlining — strips indentation and collapses whitespace
119
+ * between tags. Preserves content inside <pre>, <code>, and <textarea>
120
+ * blocks verbatim so syntax-highlighted code samples survive.
121
+ */
122
+ function minifyHTML(html) {
123
+ const preserved = [];
124
+ // Protect <pre>…</pre> and <textarea>…</textarea> (multiline code blocks)
125
+ html = html.replace(/<(pre|textarea)(\b[^>]*)>[\s\S]*?<\/\1>/gi, m => {
126
+ preserved.push(m);
127
+ return `\x00P${preserved.length - 1}\x00`;
128
+ });
129
+ // Strip HTML comments
130
+ html = html.replace(/<!--[\s\S]*?-->/g, '');
131
+ // Collapse runs of whitespace (newlines + indentation) to a single space
132
+ html = html.replace(/\s{2,}/g, ' ');
133
+ // Remove space between tags: "> <" → "><"
134
+ // but preserve a space when an inline element is involved (a, span, strong, em, b, i, code, small, sub, sup, abbr, label)
135
+ html = html.replace(/>\s+</g, (m, offset) => {
136
+ const before = html.slice(Math.max(0, offset - 80), offset + 1);
137
+ const after = html.slice(offset + m.length - 1, offset + m.length + 40);
138
+ const inlineTags = /\b(a|span|strong|em|b|i|code|small|sub|sup|abbr|label)\b/i;
139
+ const closingInline = /<\/\s*(a|span|strong|em|b|i|code|small|sub|sup|abbr|label)\s*>$/i.test(before);
140
+ const openingInline = /^<(a|span|strong|em|b|i|code|small|sub|sup|abbr|label)[\s>]/i.test(after);
141
+ return (closingInline || openingInline) ? '> <' : '><';
142
+ });
143
+ // Remove spaces inside opening tags: <tag attr = "val" > → <tag attr="val">
144
+ html = html.replace(/ *\/ *>/g, '/>');
145
+ html = html.replace(/ *= */g, '=');
146
+ // Trim
147
+ html = html.trim();
148
+ // Restore preserved blocks
149
+ html = html.replace(/\x00P(\d+)\x00/g, (_, i) => preserved[+i]);
150
+ return html;
151
+ }
152
+
153
+ /**
154
+ * Minify CSS for inlining — strips comments, collapses whitespace,
155
+ * removes unnecessary spaces around punctuation.
156
+ */
157
+ function minifyCSS(css) {
158
+ // Strip block comments
159
+ css = css.replace(/\/\*[\s\S]*?\*\//g, '');
160
+ // Collapse whitespace
161
+ css = css.replace(/\s{2,}/g, ' ');
162
+ // Remove spaces around { } : ; ,
163
+ css = css.replace(/\s*([{};:,])\s*/g, '$1');
164
+ // Remove trailing semicolons before }
165
+ css = css.replace(/;}/g, '}');
166
+ return css.trim();
167
+ }
168
+
169
+ /**
170
+ * Walk JS source and minify the HTML/CSS inside template literals.
171
+ * Handles ${…} interpolations (with nesting) and preserves <pre> blocks.
172
+ * Only trims whitespace sequences that appear between/around HTML tags.
173
+ */
174
+ function minifyTemplateLiterals(code) {
175
+ let out = '';
176
+ let i = 0;
177
+ while (i < code.length) {
178
+ const ch = code[i];
179
+
180
+ // Regular string: copy verbatim
181
+ if (ch === '"' || ch === "'") {
182
+ const q = ch; out += ch; i++;
183
+ while (i < code.length) {
184
+ if (code[i] === '\\') { out += code[i] + (code[i + 1] || ''); i += 2; continue; }
185
+ out += code[i];
186
+ if (code[i] === q) { i++; break; }
187
+ i++;
188
+ }
189
+ continue;
190
+ }
191
+
192
+ // Line comment: copy verbatim
193
+ if (ch === '/' && code[i + 1] === '/') {
194
+ while (i < code.length && code[i] !== '\n') out += code[i++];
195
+ continue;
196
+ }
197
+ // Block comment: copy verbatim
198
+ if (ch === '/' && code[i + 1] === '*') {
199
+ out += '/*'; i += 2;
200
+ while (i < code.length && !(code[i] === '*' && code[i + 1] === '/')) out += code[i++];
201
+ out += '*/'; i += 2;
202
+ continue;
203
+ }
204
+
205
+ // Template literal: extract, minify HTML, and emit
206
+ if (ch === '`') {
207
+ out += _minifyTemplate(code, i);
208
+ // Advance past the template
209
+ i = _skipTemplateLiteral(code, i);
210
+ continue;
211
+ }
212
+
213
+ out += ch; i++;
214
+ }
215
+ return out;
216
+ }
217
+
218
+ /** Extract a full template literal (handling nested ${…}) and return it minified. */
219
+ function _minifyTemplate(code, start) {
220
+ const end = _skipTemplateLiteral(code, start);
221
+ const raw = code.substring(start, end);
222
+ // Only minify templates that contain HTML tags or CSS rules
223
+ if (/<\w/.test(raw)) return _collapseTemplateWS(raw);
224
+ if (/[{};]\s/.test(raw) && /[.#\w-]+\s*\{/.test(raw)) return _collapseTemplateCSS(raw);
225
+ return raw;
226
+ }
227
+
228
+ /** Return index past the closing backtick of a template starting at `start`. */
229
+ function _skipTemplateLiteral(code, start) {
230
+ let i = start + 1; // skip opening backtick
231
+ let depth = 0;
232
+ while (i < code.length) {
233
+ if (code[i] === '\\') { i += 2; continue; }
234
+ if (code[i] === '$' && code[i + 1] === '{') { depth++; i += 2; continue; }
235
+ if (depth > 0) {
236
+ if (code[i] === '{') { depth++; i++; continue; }
237
+ if (code[i] === '}') { depth--; i++; continue; }
238
+ if (code[i] === '`') { i = _skipTemplateLiteral(code, i); continue; }
239
+ if (code[i] === '"' || code[i] === "'") {
240
+ const q = code[i]; i++;
241
+ while (i < code.length) {
242
+ if (code[i] === '\\') { i += 2; continue; }
243
+ if (code[i] === q) { i++; break; }
244
+ i++;
245
+ }
246
+ continue;
247
+ }
248
+ i++; continue;
249
+ }
250
+ if (code[i] === '`') { i++; return i; } // closing backtick
251
+ i++;
252
+ }
253
+ return i;
254
+ }
255
+
256
+ /**
257
+ * Collapse whitespace in the text portions of an HTML template literal,
258
+ * preserving ${…} expressions, <pre> blocks, and inline text spacing.
259
+ */
260
+ function _collapseTemplateWS(tpl) {
261
+ // Build array of segments: text portions vs ${…} expressions
262
+ const segments = [];
263
+ let i = 1; // skip opening backtick
264
+ let text = '';
265
+ while (i < tpl.length - 1) { // stop before closing backtick
266
+ if (tpl[i] === '\\') { text += tpl[i] + (tpl[i + 1] || ''); i += 2; continue; }
267
+ if (tpl[i] === '$' && tpl[i + 1] === '{') {
268
+ if (text) { segments.push({ type: 'text', val: text }); text = ''; }
269
+ // Collect the full expression
270
+ let depth = 1; let expr = '${'; i += 2;
271
+ while (i < tpl.length - 1 && depth > 0) {
272
+ if (tpl[i] === '\\') { expr += tpl[i] + (tpl[i + 1] || ''); i += 2; continue; }
273
+ if (tpl[i] === '{') depth++;
274
+ if (tpl[i] === '}') depth--;
275
+ if (depth > 0) { expr += tpl[i]; i++; } else { expr += '}'; i++; }
276
+ }
277
+ segments.push({ type: 'expr', val: expr });
278
+ continue;
279
+ }
280
+ text += tpl[i]; i++;
281
+ }
282
+ if (text) segments.push({ type: 'text', val: text });
283
+
284
+ // Minify text segments (collapse whitespace between/around tags)
285
+ for (let s = 0; s < segments.length; s++) {
286
+ if (segments[s].type !== 'text') continue;
287
+ let t = segments[s].val;
288
+ // Protect <pre>…</pre> regions
289
+ const preserved = [];
290
+ t = t.replace(/<pre(\b[^>]*)>[\s\S]*?<\/pre>/gi, m => {
291
+ preserved.push(m);
292
+ return `\x00P${preserved.length - 1}\x00`;
293
+ });
294
+ // Collapse whitespace runs that touch a < or > but preserve a space
295
+ // when an inline element boundary is involved
296
+ t = t.replace(/>\s{2,}/g, (m, offset) => {
297
+ const before = t.slice(Math.max(0, offset - 80), offset + 1);
298
+ if (/<\/\s*(a|span|strong|em|b|i|code|small|sub|sup|abbr|label)\s*>$/i.test(before)) return '> ';
299
+ return '>';
300
+ });
301
+ t = t.replace(/\s{2,}</g, (m, offset) => {
302
+ const after = t.slice(offset + m.length - 1, offset + m.length + 40);
303
+ if (/^<(a|span|strong|em|b|i|code|small|sub|sup|abbr|label)[\s>]/i.test(after)) return ' <';
304
+ return '<';
305
+ });
306
+ // Collapse other multi-whitespace runs to a single space
307
+ t = t.replace(/\s{2,}/g, ' ');
308
+ // Restore <pre> blocks
309
+ t = t.replace(/\x00P(\d+)\x00/g, (_, idx) => preserved[+idx]);
310
+ segments[s].val = t;
311
+ }
312
+
313
+ return '`' + segments.map(s => s.val).join('') + '`';
314
+ }
315
+
316
+ /**
317
+ * Collapse CSS whitespace inside a template literal (styles: `…`).
318
+ * Uses the same segment-splitting approach as _collapseTemplateWS so
319
+ * ${…} expressions are preserved untouched.
320
+ */
321
+ function _collapseTemplateCSS(tpl) {
322
+ const segments = [];
323
+ let i = 1; let text = '';
324
+ while (i < tpl.length - 1) {
325
+ if (tpl[i] === '\\') { text += tpl[i] + (tpl[i + 1] || ''); i += 2; continue; }
326
+ if (tpl[i] === '$' && tpl[i + 1] === '{') {
327
+ if (text) { segments.push({ type: 'text', val: text }); text = ''; }
328
+ let depth = 1; let expr = '${'; i += 2;
329
+ while (i < tpl.length - 1 && depth > 0) {
330
+ if (tpl[i] === '\\') { expr += tpl[i] + (tpl[i + 1] || ''); i += 2; continue; }
331
+ if (tpl[i] === '{') depth++;
332
+ if (tpl[i] === '}') depth--;
333
+ if (depth > 0) { expr += tpl[i]; i++; } else { expr += '}'; i++; }
334
+ }
335
+ segments.push({ type: 'expr', val: expr });
336
+ continue;
337
+ }
338
+ text += tpl[i]; i++;
339
+ }
340
+ if (text) segments.push({ type: 'text', val: text });
341
+
342
+ for (let s = 0; s < segments.length; s++) {
343
+ if (segments[s].type !== 'text') continue;
344
+ let t = segments[s].val;
345
+ t = t.replace(/\/\*[\s\S]*?\*\//g, '');
346
+ t = t.replace(/\s{2,}/g, ' ');
347
+ t = t.replace(/\s*([{};:,])\s*/g, '$1');
348
+ t = t.replace(/;}/g, '}');
349
+ segments[s].val = t;
350
+ }
351
+ return '`' + segments.map(s => s.val).join('') + '`';
352
+ }
353
+
115
354
  /**
116
355
  * Scan bundled source files for external resource references
117
356
  * (pages config, templateUrl, styleUrl) and return a map of
@@ -152,7 +391,8 @@ function collectInlineResources(files, projectRoot) {
152
391
  }
153
392
 
154
393
  // styleUrl:
155
- const styleMatch = code.match(/styleUrl\s*:\s*['"]([^'"]+)['"]/);
394
+ const styleUrlRe = /styleUrl\s*:\s*['"]([^'"]+)['"]/g;
395
+ const styleMatch = styleUrlRe.exec(code);
156
396
  if (styleMatch) {
157
397
  const stylePath = path.join(fileDir, styleMatch[1]);
158
398
  if (fs.existsSync(stylePath)) {
@@ -172,6 +412,12 @@ function collectInlineResources(files, projectRoot) {
172
412
  }
173
413
  }
174
414
 
415
+ // Minify inlined resources by type
416
+ for (const key of Object.keys(inlineMap)) {
417
+ if (key.endsWith('.html')) inlineMap[key] = minifyHTML(inlineMap[key]);
418
+ else if (key.endsWith('.css')) inlineMap[key] = minifyCSS(inlineMap[key]);
419
+ }
420
+
175
421
  return inlineMap;
176
422
  }
177
423
 
@@ -316,6 +562,37 @@ function rewriteHtml(projectRoot, htmlRelPath, bundleFile, includeLib, bundledFi
316
562
  assets.add(ref);
317
563
  }
318
564
 
565
+ // Also scan the bundled JS for src/href references to local assets
566
+ const bundleContent = fs.existsSync(bundleFile) ? fs.readFileSync(bundleFile, 'utf-8') : '';
567
+ const jsAssetRe = /\b(?:src|href)\s*=\s*["\\]*["']([^"']+\.(?:svg|png|jpg|jpeg|gif|webp|ico|woff2?|ttf|eot|mp4|webm))["']/gi;
568
+ while ((m = jsAssetRe.exec(bundleContent)) !== null) {
569
+ const ref = m[1];
570
+ if (ref.startsWith('http') || ref.startsWith('//') || ref.startsWith('data:')) continue;
571
+ if (!assets.has(ref)) {
572
+ const refAbs = path.resolve(htmlDir, ref);
573
+ if (fs.existsSync(refAbs)) assets.add(ref);
574
+ }
575
+ }
576
+
577
+ // For any referenced asset directories, copy all sibling files too
578
+ const assetDirs = new Set();
579
+ for (const asset of assets) {
580
+ const dir = path.dirname(asset);
581
+ if (dir && dir !== '.') assetDirs.add(dir);
582
+ }
583
+ for (const dir of assetDirs) {
584
+ const absDirPath = path.resolve(htmlDir, dir);
585
+ if (fs.existsSync(absDirPath) && fs.statSync(absDirPath).isDirectory()) {
586
+ for (const child of fs.readdirSync(absDirPath)) {
587
+ const childRel = path.join(dir, child).replace(/\\/g, '/');
588
+ const childAbs = path.join(absDirPath, child);
589
+ if (fs.statSync(childAbs).isFile() && !assets.has(childRel)) {
590
+ assets.add(childRel);
591
+ }
592
+ }
593
+ }
594
+ }
595
+
319
596
  // Copy assets into both dist dirs
320
597
  let copiedCount = 0;
321
598
  for (const asset of assets) {
@@ -574,8 +851,9 @@ function bundleApp() {
574
851
  code = stripModuleSyntax(code);
575
852
  code = replaceImportMeta(code, file, projectRoot);
576
853
  code = rewriteResourceUrls(code, file, projectRoot);
854
+ code = minifyTemplateLiterals(code);
577
855
  const rel = path.relative(projectRoot, file);
578
- return `// --- ${rel} ${''.repeat(Math.max(1, 60 - rel.length))}\n${code.trim()}`;
856
+ return `// --- ${rel} ${'-'.repeat(Math.max(1, 60 - rel.length))}\n${code.trim()}`;
579
857
  });
580
858
 
581
859
  // Embed zquery.min.js
@@ -612,8 +890,8 @@ function bundleApp() {
612
890
 
613
891
  if (libPath) {
614
892
  const libBytes = fs.statSync(libPath).size;
615
- libSection = `// --- zquery.min.js (library) ${''.repeat(34)}\n${fs.readFileSync(libPath, 'utf-8').trim()}\n\n`
616
- + `// --- Build-time metadata ————————————————————————————\nif(typeof $!=="undefined"){$.meta=Object.assign($.meta||{},{libSize:${libBytes}});}\n\n`;
893
+ libSection = `// --- zquery.min.js (library) ${'-'.repeat(34)}\n${fs.readFileSync(libPath, 'utf-8').trim()}\n\n`
894
+ + `// --- Build-time metadata ${'-'.repeat(50)}\nif(typeof $!=="undefined"){$.meta=Object.assign($.meta||{},{libSize:${libBytes}});}\n\n`;
617
895
  console.log(` Embedded library from ${path.relative(projectRoot, libPath)} (${(libBytes / 1024).toFixed(1)} KB)`);
618
896
  } else {
619
897
  console.warn(`\n ⚠ Could not find zquery.min.js anywhere`);
@@ -621,7 +899,7 @@ function bundleApp() {
621
899
  }
622
900
  }
623
901
 
624
- const banner = `/**\n * App bundle built by zQuery CLI\n * Entry: ${entryRel}\n * ${new Date().toISOString()}\n */`;
902
+ const banner = `/**\n * App bundle - built by zQuery CLI\n * Entry: ${entryRel}\n * ${new Date().toISOString()}\n */`;
625
903
 
626
904
  // Inline resources
627
905
  const inlineMap = collectInlineResources(files, projectRoot);
@@ -635,7 +913,7 @@ function bundleApp() {
635
913
  .replace(/\r/g, '');
636
914
  return ` '${key}': '${escaped}'`;
637
915
  });
638
- inlineSection = `// --- Inlined resources (file:// support) ${''.repeat(20)}\nwindow.__zqInline = {\n${entries.join(',\n')}\n};\n\n`;
916
+ inlineSection = `// --- Inlined resources (file:// support) ${'-'.repeat(20)}\nwindow.__zqInline = {\n${entries.join(',\n')}\n};\n\n`;
639
917
  console.log(`\n Inlined ${Object.keys(inlineMap).length} external resource(s)`);
640
918
  }
641
919
 
@@ -668,10 +946,10 @@ function bundleApp() {
668
946
  console.log(`\n ✓ ${bundleBase} (${sizeKB(fs.readFileSync(bundleFile))} KB)`);
669
947
  console.log(` ✓ ${minBase} (${sizeKB(fs.readFileSync(minFile))} KB)`);
670
948
 
671
- // Rewrite HTML (use full bundle minified version mangles template literal whitespace)
949
+ // Rewrite HTML to reference the minified bundle
672
950
  const bundledFileSet = new Set(files);
673
951
  if (htmlFile) {
674
- rewriteHtml(projectRoot, htmlFile, bundleFile, true, bundledFileSet, serverDir, localDir);
952
+ rewriteHtml(projectRoot, htmlFile, minFile, true, bundledFileSet, serverDir, localDir);
675
953
  }
676
954
 
677
955
  // Copy static asset directories (icons/, images/, fonts/, etc.)
@@ -46,14 +46,14 @@ function resolveRoot(htmlEntry) {
46
46
  // devServer — main entry point (called from cli/index.js)
47
47
  // ---------------------------------------------------------------------------
48
48
 
49
- function devServer() {
49
+ async function devServer() {
50
50
  const htmlEntry = option('index', 'i', 'index.html');
51
51
  const port = parseInt(option('port', 'p', '3100'), 10);
52
52
  const noIntercept = flag('no-intercept');
53
53
  const root = resolveRoot(htmlEntry);
54
54
 
55
55
  // Start HTTP server + SSE pool
56
- const { app, pool, listen } = createServer({ root, htmlEntry, port, noIntercept });
56
+ const { app, pool, listen } = await createServer({ root, htmlEntry, port, noIntercept });
57
57
 
58
58
  // Start file watcher
59
59
  const watcher = startWatcher({ root, pool });
@@ -281,14 +281,63 @@ const OVERLAY_SCRIPT = `<script>
281
281
  location.reload();
282
282
  });
283
283
 
284
- es.addEventListener('css', function() {
284
+ es.addEventListener('css', function(e) {
285
+ var changedPath = (e.data || '').replace(/^\\/+/, '');
286
+ var matched = false;
287
+
288
+ // 1) Try cache-busting matching <link rel="stylesheet"> tags
285
289
  var sheets = document.querySelectorAll('link[rel="stylesheet"]');
286
290
  sheets.forEach(function(l) {
287
291
  var href = l.getAttribute('href');
288
292
  if (!href) return;
293
+ var clean = href.replace(/[?&]_zqr=\\d+/, '').replace(/^\\/+/, '');
294
+ if (changedPath && clean.indexOf(changedPath) === -1) return;
295
+ matched = true;
289
296
  var sep = href.indexOf('?') >= 0 ? '&' : '?';
290
- l.setAttribute('href', href.replace(/[?&]_zqr=\\\\d+/, '') + sep + '_zqr=' + Date.now());
297
+ l.setAttribute('href', href.replace(/[?&]_zqr=\\d+/, '') + sep + '_zqr=' + Date.now());
291
298
  });
299
+
300
+ // 2) Try hot-swapping scoped <style data-zq-style-urls> elements
301
+ // These come from component styleUrl — the CSS was fetched, scoped,
302
+ // and injected as an inline <style>. We re-fetch and re-scope it.
303
+ if (!matched) {
304
+ var scopedEls = document.querySelectorAll('style[data-zq-style-urls]');
305
+ scopedEls.forEach(function(el) {
306
+ var urls = el.getAttribute('data-zq-style-urls') || '';
307
+ var hit = urls.split(' ').some(function(u) {
308
+ return u && u.replace(/^\\/+/, '').indexOf(changedPath) !== -1;
309
+ });
310
+ if (!hit) return;
311
+ matched = true;
312
+
313
+ var scopeAttr = el.getAttribute('data-zq-scope') || '';
314
+ var inlineStyles = el.getAttribute('data-zq-inline') || '';
315
+
316
+ // Re-fetch all style URLs (cache-busted)
317
+ var urlList = urls.split(' ').filter(Boolean);
318
+ Promise.all(urlList.map(function(u) {
319
+ return fetch(u + (u.indexOf('?') >= 0 ? '&' : '?') + '_zqr=' + Date.now())
320
+ .then(function(r) { return r.text(); });
321
+ })).then(function(results) {
322
+ var raw = (inlineStyles ? inlineStyles + '\\n' : '') + results.join('\\n');
323
+ // Re-scope CSS with the same scope attribute
324
+ if (scopeAttr) {
325
+ var inAt = 0;
326
+ raw = raw.replace(/([^{}]+)\\{|\\}/g, function(m, sel) {
327
+ if (m === '}') { if (inAt > 0) inAt--; return m; }
328
+ var t = sel.trim();
329
+ if (t.charAt(0) === '@') { inAt++; return m; }
330
+ if (inAt > 0 && /^[\\d%\\s,fromto]+$/.test(t.replace(/\\s/g, ''))) return m;
331
+ return sel.split(',').map(function(s) { return '[' + scopeAttr + '] ' + s.trim(); }).join(', ') + ' {';
332
+ });
333
+ }
334
+ el.textContent = raw;
335
+ });
336
+ });
337
+ }
338
+
339
+ // 3) Nothing matched — fall back to full reload
340
+ if (!matched) { location.reload(); }
292
341
  });
293
342
 
294
343
  es.addEventListener('error:syntax', function(e) {
@@ -44,22 +44,51 @@ class SSEPool {
44
44
  // Server factory
45
45
  // ---------------------------------------------------------------------------
46
46
 
47
+ /**
48
+ * Prompt the user to auto-install zero-http when it isn't found.
49
+ * Resolves `true` if the user accepts, `false` otherwise.
50
+ */
51
+ function promptInstall() {
52
+ const rl = require('readline').createInterface({
53
+ input: process.stdin,
54
+ output: process.stdout,
55
+ });
56
+ return new Promise((resolve) => {
57
+ rl.question(
58
+ '\n The local dev server requires zero-http, which is not installed.\n' +
59
+ ' This package is only used during development and is not needed\n' +
60
+ ' for building, bundling, or production.\n' +
61
+ ' Install it now? (y/n): ',
62
+ (answer) => {
63
+ rl.close();
64
+ resolve(answer.trim().toLowerCase() === 'y');
65
+ }
66
+ );
67
+ });
68
+ }
69
+
47
70
  /**
48
71
  * @param {object} opts
49
72
  * @param {string} opts.root — absolute path to project root
50
73
  * @param {string} opts.htmlEntry — e.g. 'index.html'
51
74
  * @param {number} opts.port
52
75
  * @param {boolean} opts.noIntercept — skip zquery.min.js auto-resolve
53
- * @returns {{ app, pool: SSEPool, listen: Function }}
76
+ * @returns {Promise<{ app, pool: SSEPool, listen: Function }>}
54
77
  */
55
- function createServer({ root, htmlEntry, port, noIntercept }) {
78
+ async function createServer({ root, htmlEntry, port, noIntercept }) {
56
79
  let zeroHttp;
57
80
  try {
58
81
  zeroHttp = require('zero-http');
59
82
  } catch {
60
- console.error(`\n \u2717 zero-http is required for the dev server.`);
61
- console.error(` Install it: npm install zero-http --save-dev\n`);
62
- process.exit(1);
83
+ const ok = await promptInstall();
84
+ if (!ok) {
85
+ console.error('\n ✖ Cannot start dev server without zero-http.\n');
86
+ process.exit(1);
87
+ }
88
+ const { execSync } = require('child_process');
89
+ console.log('\n Installing zero-http...\n');
90
+ execSync('npm install zero-http --save-dev', { stdio: 'inherit' });
91
+ zeroHttp = require('zero-http');
63
92
  }
64
93
 
65
94
  const { createApp, static: serveStatic } = zeroHttp;
@@ -28,6 +28,14 @@ function isIgnored(filepath) {
28
28
  return filepath.split(path.sep).some(p => IGNORE_DIRS.has(p));
29
29
  }
30
30
 
31
+ /**
32
+ * Return the file's mtime as a millisecond timestamp, or 0 if unreadable.
33
+ * Used to ignore spurious fs.watch events (Windows fires on reads too).
34
+ */
35
+ function mtime(filepath) {
36
+ try { return fs.statSync(filepath).mtimeMs; } catch { return 0; }
37
+ }
38
+
31
39
  /** Recursively collect every directory under `dir` (excluding ignored). */
32
40
  function collectWatchDirs(dir) {
33
41
  const dirs = [dir];
@@ -59,6 +67,24 @@ function startWatcher({ root, pool }) {
59
67
  let debounceTimer;
60
68
  let currentError = null; // track which file has an active error
61
69
 
70
+ // Track file mtimes so we only react to genuine writes.
71
+ // On Windows, fs.watch fires on reads/access too, which causes
72
+ // spurious reloads the first time the server serves a file.
73
+ // We seed the cache with current mtimes so the first real save
74
+ // (which changes the mtime) is always detected.
75
+ const mtimeCache = new Map();
76
+ for (const d of watchDirs) {
77
+ try {
78
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
79
+ if (entry.isFile()) {
80
+ const fp = path.join(d, entry.name);
81
+ const mt = mtime(fp);
82
+ if (mt) mtimeCache.set(fp, mt);
83
+ }
84
+ }
85
+ } catch { /* skip */ }
86
+ }
87
+
62
88
  for (const dir of watchDirs) {
63
89
  try {
64
90
  const watcher = fs.watch(dir, (_, filename) => {
@@ -68,6 +94,13 @@ function startWatcher({ root, pool }) {
68
94
 
69
95
  clearTimeout(debounceTimer);
70
96
  debounceTimer = setTimeout(() => {
97
+ // Skip if the file hasn't actually been modified
98
+ const mt = mtime(fullPath);
99
+ if (mt === 0) return; // deleted or unreadable
100
+ const prev = mtimeCache.get(fullPath);
101
+ mtimeCache.set(fullPath, mt);
102
+ if (prev !== undefined && mt === prev) return; // unchanged
103
+
71
104
  const rel = path.relative(root, fullPath).replace(/\\/g, '/');
72
105
  const ext = path.extname(filename).toLowerCase();
73
106
 
@@ -6,6 +6,7 @@
6
6
  <title>{{NAME}}</title>
7
7
  <base href="/">
8
8
  <link rel="stylesheet" href="styles/styles.css">
9
+ <link rel="icon" type="image/png" href="favicon.ico">
9
10
  <script src="scripts/vendor/zquery.min.js"></script>
10
11
  <script type="module" src="scripts/app.js"></script>
11
12
  </head>