zero-query 0.1.2 → 0.2.2

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.
package/cli.js ADDED
@@ -0,0 +1,833 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * zQuery CLI
5
+ *
6
+ * Zero-dependency command-line tool for building the zQuery library
7
+ * and bundling zQuery-based applications into a single file.
8
+ *
9
+ * Usage:
10
+ * zquery build Build the zQuery library (dist/)
11
+ *
12
+ * zquery bundle [entry] Bundle an app's ES modules into one file
13
+ * zquery bundle scripts/app.js Specify entry explicitly
14
+ * zquery bundle -o build/ Custom output directory
15
+ * zquery bundle --html other.html Use a specific HTML file instead of auto-detected
16
+ * zquery bundle --watch Watch & rebuild on changes
17
+ *
18
+ * Smart defaults (no flags needed for typical projects):
19
+ * - Entry is auto-detected from index.html's <script type="module" src="...">
20
+ * - zquery.min.js is always embedded (auto-built if not found)
21
+ * - index.html is always rewritten and assets are copied
22
+ * - Output goes to dist/ next to the detected index.html
23
+ *
24
+ * Examples:
25
+ * cd my-app && npx zero-query bundle # just works!
26
+ * npx zero-query bundle path/to/scripts/app.js # works from anywhere
27
+ */
28
+
29
+ const fs = require('fs');
30
+ const path = require('path');
31
+ const crypto = require('crypto');
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // CLI argument parsing
35
+ // ---------------------------------------------------------------------------
36
+
37
+ const args = process.argv.slice(2);
38
+ const command = args[0];
39
+
40
+ function flag(name, short) {
41
+ const i = args.indexOf(`--${name}`);
42
+ const j = short ? args.indexOf(`-${short}`) : -1;
43
+ return i !== -1 || j !== -1;
44
+ }
45
+
46
+ function option(name, short, fallback) {
47
+ let i = args.indexOf(`--${name}`);
48
+ if (i === -1 && short) i = args.indexOf(`-${short}`);
49
+ if (i !== -1 && i + 1 < args.length) return args[i + 1];
50
+ return fallback;
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Shared utilities
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /**
58
+ * Context-aware comment stripper — skips strings, templates, regex.
59
+ * Reused from build.js.
60
+ */
61
+ function stripComments(code) {
62
+ let out = '';
63
+ let i = 0;
64
+ while (i < code.length) {
65
+ const ch = code[i];
66
+ const next = code[i + 1];
67
+
68
+ // String literals
69
+ if (ch === '"' || ch === "'" || ch === '`') {
70
+ const quote = ch;
71
+ out += ch; i++;
72
+ while (i < code.length) {
73
+ if (code[i] === '\\') { out += code[i] + (code[i + 1] || ''); i += 2; continue; }
74
+ out += code[i];
75
+ if (code[i] === quote) { i++; break; }
76
+ i++;
77
+ }
78
+ continue;
79
+ }
80
+
81
+ // Block comment
82
+ if (ch === '/' && next === '*') {
83
+ i += 2;
84
+ while (i < code.length && !(code[i] === '*' && code[i + 1] === '/')) i++;
85
+ i += 2;
86
+ continue;
87
+ }
88
+
89
+ // Line comment
90
+ if (ch === '/' && next === '/') {
91
+ i += 2;
92
+ while (i < code.length && code[i] !== '\n') i++;
93
+ continue;
94
+ }
95
+
96
+ // Regex literal
97
+ if (ch === '/') {
98
+ const before = out.replace(/\s+$/, '');
99
+ const last = before[before.length - 1];
100
+ const isRegexCtx = !last || '=({[,;:!&|?~+-*/%^>'.includes(last)
101
+ || before.endsWith('return') || before.endsWith('typeof')
102
+ || before.endsWith('case') || before.endsWith('in')
103
+ || before.endsWith('delete') || before.endsWith('void')
104
+ || before.endsWith('throw') || before.endsWith('new');
105
+ if (isRegexCtx) {
106
+ out += ch; i++;
107
+ let inCharClass = false;
108
+ while (i < code.length) {
109
+ const rc = code[i];
110
+ if (rc === '\\') { out += rc + (code[i + 1] || ''); i += 2; continue; }
111
+ if (rc === '[') inCharClass = true;
112
+ if (rc === ']') inCharClass = false;
113
+ out += rc; i++;
114
+ if (rc === '/' && !inCharClass) {
115
+ while (i < code.length && /[gimsuy]/.test(code[i])) { out += code[i]; i++; }
116
+ break;
117
+ }
118
+ }
119
+ continue;
120
+ }
121
+ }
122
+
123
+ out += ch; i++;
124
+ }
125
+ return out;
126
+ }
127
+
128
+ /** Quick minification (same approach as build.js). */
129
+ function minify(code, banner) {
130
+ const body = stripComments(code.replace(banner, ''))
131
+ .replace(/^\s*\n/gm, '')
132
+ .replace(/\n\s+/g, '\n')
133
+ .replace(/\s{2,}/g, ' ');
134
+ return banner + '\n' + body;
135
+ }
136
+
137
+ function sizeKB(buf) {
138
+ return (buf.length / 1024).toFixed(1);
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // "build" command — library build (mirrors build.js)
143
+ // ---------------------------------------------------------------------------
144
+
145
+ function buildLibrary() {
146
+ const pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf-8'));
147
+ const VERSION = pkg.version;
148
+
149
+ const modules = [
150
+ 'src/reactive.js', 'src/core.js', 'src/component.js',
151
+ 'src/router.js', 'src/store.js', 'src/http.js', 'src/utils.js',
152
+ ];
153
+
154
+ const DIST = path.join(process.cwd(), 'dist');
155
+ const OUT_FILE = path.join(DIST, 'zquery.js');
156
+ const MIN_FILE = path.join(DIST, 'zquery.min.js');
157
+
158
+ const start = Date.now();
159
+ if (!fs.existsSync(DIST)) fs.mkdirSync(DIST, { recursive: true });
160
+
161
+ const parts = modules.map(file => {
162
+ let code = fs.readFileSync(path.join(process.cwd(), file), 'utf-8');
163
+ code = code.replace(/^import\s+[\s\S]*?from\s+['"].*?['"];?\s*$/gm, '');
164
+ code = code.replace(/^export\s+(default\s+)?/gm, '');
165
+ code = code.replace(/^export\s*\{[\s\S]*?\};\s*$/gm, '');
166
+ return `// --- ${file} ${'—'.repeat(60 - file.length)}\n${code.trim()}`;
167
+ });
168
+
169
+ let indexCode = fs.readFileSync(path.join(process.cwd(), 'index.js'), 'utf-8');
170
+ indexCode = indexCode.replace(/^import\s+[\s\S]*?from\s+['"].*?['"];?\s*$/gm, '');
171
+ indexCode = indexCode.replace(/^export\s*\{[\s\S]*?\};\s*$/gm, '');
172
+ indexCode = indexCode.replace(/^export\s+(default\s+)?/gm, '');
173
+
174
+ 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 */`;
175
+
176
+ 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`;
177
+
178
+ fs.writeFileSync(OUT_FILE, bundle, 'utf-8');
179
+ fs.writeFileSync(MIN_FILE, minify(bundle, banner), 'utf-8');
180
+
181
+ const elapsed = Date.now() - start;
182
+ console.log(` ✓ dist/zquery.js (${sizeKB(fs.readFileSync(OUT_FILE))} KB)`);
183
+ console.log(` ✓ dist/zquery.min.js (${sizeKB(fs.readFileSync(MIN_FILE))} KB)`);
184
+ console.log(` Done in ${elapsed}ms\n`);
185
+
186
+ return { DIST, OUT_FILE, MIN_FILE };
187
+ }
188
+
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // "bundle" command — app bundler
192
+ // ---------------------------------------------------------------------------
193
+
194
+ /**
195
+ * Resolve an import specifier relative to the importing file.
196
+ */
197
+ function resolveImport(specifier, fromFile) {
198
+ if (!specifier.startsWith('.') && !specifier.startsWith('/')) return null; // bare specifier
199
+ let resolved = path.resolve(path.dirname(fromFile), specifier);
200
+ // If no extension and a .js file exists, add it
201
+ if (!path.extname(resolved) && fs.existsSync(resolved + '.js')) {
202
+ resolved += '.js';
203
+ }
204
+ return resolved;
205
+ }
206
+
207
+ /**
208
+ * Extract import specifiers from a source file.
209
+ * Handles: import 'x', import x from 'x', import { a } from 'x', import * as x from 'x'
210
+ * (including multi-line destructured imports)
211
+ */
212
+ function extractImports(code) {
213
+ const specifiers = [];
214
+ let m;
215
+ // Pattern 1: import ... from 'specifier' (works for single & multi-line)
216
+ const fromRe = /\bfrom\s+['"]([^'"]+)['"]/g;
217
+ while ((m = fromRe.exec(code)) !== null) {
218
+ specifiers.push(m[1]);
219
+ }
220
+ // Pattern 2: side-effect imports — import './foo.js';
221
+ const sideRe = /^\s*import\s+['"]([^'"]+)['"]\s*;?\s*$/gm;
222
+ while ((m = sideRe.exec(code)) !== null) {
223
+ if (!specifiers.includes(m[1])) specifiers.push(m[1]);
224
+ }
225
+ return specifiers;
226
+ }
227
+
228
+ /**
229
+ * Walk the import graph starting from `entry`, return files in dependency
230
+ * order (leaves first — topological sort).
231
+ */
232
+ function walkImportGraph(entry) {
233
+ const visited = new Set();
234
+ const order = [];
235
+
236
+ function visit(file) {
237
+ const abs = path.resolve(file);
238
+ if (visited.has(abs)) return;
239
+ visited.add(abs);
240
+
241
+ if (!fs.existsSync(abs)) {
242
+ console.warn(` ⚠ Missing file: ${abs}`);
243
+ return;
244
+ }
245
+
246
+ const code = fs.readFileSync(abs, 'utf-8');
247
+ const imports = extractImports(code);
248
+
249
+ for (const spec of imports) {
250
+ const resolved = resolveImport(spec, abs);
251
+ if (resolved) visit(resolved);
252
+ }
253
+
254
+ order.push(abs);
255
+ }
256
+
257
+ visit(entry);
258
+ return order;
259
+ }
260
+
261
+ /**
262
+ * Strip ES module import/export syntax from code, keeping declarations.
263
+ */
264
+ function stripModuleSyntax(code) {
265
+ // Remove import lines (single-line and multi-line from ... )
266
+ code = code.replace(/^\s*import\s+[\s\S]*?from\s+['"].*?['"];?\s*$/gm, '');
267
+ // Remove side-effect imports import './foo.js';
268
+ code = code.replace(/^\s*import\s+['"].*?['"];?\s*$/gm, '');
269
+ // Remove export default but keep the expression
270
+ code = code.replace(/^(\s*)export\s+default\s+/gm, '$1');
271
+ // Remove export keyword but keep declarations
272
+ code = code.replace(/^(\s*)export\s+(const|let|var|function|class|async\s+function)\s/gm, '$1$2 ');
273
+ // Remove standalone export { ... } blocks
274
+ code = code.replace(/^\s*export\s*\{[\s\S]*?\};?\s*$/gm, '');
275
+ return code;
276
+ }
277
+
278
+ /**
279
+ * Replace `import.meta.url` with a runtime equivalent.
280
+ * In a non-module <script>, import.meta doesn't exist, so we substitute
281
+ * document.currentScript.src (set at load time) relative to the original
282
+ * file's path inside the project.
283
+ */
284
+ function replaceImportMeta(code, filePath, projectRoot) {
285
+ if (!code.includes('import.meta')) return code;
286
+ // Compute the web-relative path of this file from the project root
287
+ const rel = path.relative(projectRoot, filePath).replace(/\\/g, '/');
288
+ // Replace import.meta.url with a constructed URL based on the page origin
289
+ code = code.replace(
290
+ /import\.meta\.url/g,
291
+ `(new URL('${rel}', document.baseURI).href)`
292
+ );
293
+ return code;
294
+ }
295
+
296
+ /**
297
+ * Scan bundled source files for external resource references
298
+ * (pages config, templateUrl, styleUrl) and read those files so they
299
+ * can be inlined into the bundle for file:// support.
300
+ *
301
+ * Returns a map of { relativePath: fileContent }.
302
+ */
303
+ function collectInlineResources(files, projectRoot) {
304
+ const inlineMap = {};
305
+
306
+ for (const file of files) {
307
+ const code = fs.readFileSync(file, 'utf-8');
308
+ const fileDir = path.dirname(file);
309
+
310
+ // Detect `pages:` config — look for dir and items
311
+ const pagesMatch = code.match(/pages\s*:\s*\{[^}]*dir\s*:\s*['"]([^'"]+)['"]/s);
312
+ if (pagesMatch) {
313
+ const pagesDir = pagesMatch[1];
314
+ const ext = (code.match(/pages\s*:\s*\{[^}]*ext\s*:\s*['"]([^'"]+)['"]/s) || [])[1] || '.html';
315
+
316
+ // Extract item IDs from the items array
317
+ const itemsMatch = code.match(/items\s*:\s*\[([\s\S]*?)\]/);
318
+ if (itemsMatch) {
319
+ const itemsBlock = itemsMatch[1];
320
+ const ids = [];
321
+ // Match string items: 'getting-started'
322
+ let m;
323
+ const strRe = /['"]([^'"]+)['"]/g;
324
+ const objIdRe = /id\s*:\s*['"]([^'"]+)['"]/g;
325
+ // Collect all quoted strings that look like page IDs
326
+ while ((m = strRe.exec(itemsBlock)) !== null) {
327
+ // Skip labels (preceded by "label:")
328
+ const before = itemsBlock.substring(Math.max(0, m.index - 20), m.index);
329
+ if (/label\s*:\s*$/.test(before)) continue;
330
+ ids.push(m[1]);
331
+ }
332
+
333
+ // Read each page file
334
+ const absPagesDir = path.join(fileDir, pagesDir);
335
+ for (const id of ids) {
336
+ const pagePath = path.join(absPagesDir, id + ext);
337
+ if (fs.existsSync(pagePath)) {
338
+ const relKey = path.relative(projectRoot, pagePath).replace(/\\/g, '/');
339
+ inlineMap[relKey] = fs.readFileSync(pagePath, 'utf-8');
340
+ }
341
+ }
342
+ }
343
+ }
344
+
345
+ // Detect `styleUrl:` — single string
346
+ const styleMatch = code.match(/styleUrl\s*:\s*['"]([^'"]+)['"]/);
347
+ if (styleMatch) {
348
+ const stylePath = path.join(fileDir, styleMatch[1]);
349
+ if (fs.existsSync(stylePath)) {
350
+ const relKey = path.relative(projectRoot, stylePath).replace(/\\/g, '/');
351
+ inlineMap[relKey] = fs.readFileSync(stylePath, 'utf-8');
352
+ }
353
+ }
354
+
355
+ // Detect `templateUrl:` — single string
356
+ const tmplMatch = code.match(/templateUrl\s*:\s*['"]([^'"]+)['"]/);
357
+ if (tmplMatch) {
358
+ const tmplPath = path.join(fileDir, tmplMatch[1]);
359
+ if (fs.existsSync(tmplPath)) {
360
+ const relKey = path.relative(projectRoot, tmplPath).replace(/\\/g, '/');
361
+ inlineMap[relKey] = fs.readFileSync(tmplPath, 'utf-8');
362
+ }
363
+ }
364
+ }
365
+
366
+ return inlineMap;
367
+ }
368
+
369
+ /**
370
+ * Try to auto-detect the app entry point.
371
+ * Looks for <script type="module" src="..."> in an index.html,
372
+ * or falls back to common conventions.
373
+ */
374
+ function detectEntry(projectRoot) {
375
+ const htmlCandidates = ['index.html', 'public/index.html'];
376
+ for (const htmlFile of htmlCandidates) {
377
+ const htmlPath = path.join(projectRoot, htmlFile);
378
+ if (fs.existsSync(htmlPath)) {
379
+ const html = fs.readFileSync(htmlPath, 'utf-8');
380
+ const m = html.match(/<script[^>]+type\s*=\s*["']module["'][^>]+src\s*=\s*["']([^"']+)["']/);
381
+ if (m) return path.join(projectRoot, m[1]);
382
+ }
383
+ }
384
+ // Convention fallback
385
+ const fallbacks = ['scripts/app.js', 'src/app.js', 'js/app.js', 'app.js', 'main.js'];
386
+ for (const f of fallbacks) {
387
+ const fp = path.join(projectRoot, f);
388
+ if (fs.existsSync(fp)) return fp;
389
+ }
390
+ return null;
391
+ }
392
+
393
+
394
+ function bundleApp() {
395
+ const projectRoot = process.cwd();
396
+
397
+ // Entry point
398
+ let entry = null;
399
+ // First positional arg after "bundle" that doesn't start with -
400
+ for (let i = 1; i < args.length; i++) {
401
+ if (!args[i].startsWith('-') && args[i - 1] !== '-o' && args[i - 1] !== '--out' && args[i - 1] !== '--html') {
402
+ entry = path.resolve(projectRoot, args[i]);
403
+ break;
404
+ }
405
+ }
406
+ if (!entry) entry = detectEntry(projectRoot);
407
+
408
+ if (!entry || !fs.existsSync(entry)) {
409
+ console.error(`\n ✗ Could not find entry file.`);
410
+ console.error(` Provide one explicitly: zquery bundle scripts/app.js\n`);
411
+ process.exit(1);
412
+ }
413
+
414
+ const outPath = option('out', 'o', null);
415
+ const watchMode = flag('watch', 'w');
416
+
417
+ // Auto-detect index.html by walking up from the entry file, then check cwd
418
+ let htmlFile = option('html', null, null);
419
+ let htmlAbs = htmlFile ? path.resolve(projectRoot, htmlFile) : null;
420
+ if (!htmlFile) {
421
+ const htmlCandidates = [];
422
+ // Walk up from the entry file's directory to cwd looking for index.html
423
+ let entryDir = path.dirname(entry);
424
+ while (entryDir.length >= projectRoot.length) {
425
+ htmlCandidates.push(path.join(entryDir, 'index.html'));
426
+ const parent = path.dirname(entryDir);
427
+ if (parent === entryDir) break;
428
+ entryDir = parent;
429
+ }
430
+ // Also check cwd and public/
431
+ htmlCandidates.push(path.join(projectRoot, 'index.html'));
432
+ htmlCandidates.push(path.join(projectRoot, 'public/index.html'));
433
+ for (const candidate of htmlCandidates) {
434
+ if (fs.existsSync(candidate)) {
435
+ htmlAbs = candidate;
436
+ htmlFile = path.relative(projectRoot, candidate);
437
+ break;
438
+ }
439
+ }
440
+ }
441
+
442
+ // Derive output directory:
443
+ // -o flag → use that path
444
+ // else → dist/ next to the detected HTML file (or cwd/dist/ as fallback)
445
+ const entryRel = path.relative(projectRoot, entry);
446
+ const entryName = path.basename(entry, '.js');
447
+ let baseDistDir;
448
+ if (outPath) {
449
+ const resolved = path.resolve(projectRoot, outPath);
450
+ if (outPath.endsWith('/') || outPath.endsWith('\\') || !path.extname(outPath) ||
451
+ (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory())) {
452
+ baseDistDir = resolved;
453
+ } else {
454
+ baseDistDir = path.dirname(resolved);
455
+ }
456
+ } else if (htmlAbs) {
457
+ baseDistDir = path.join(path.dirname(htmlAbs), 'dist');
458
+ } else {
459
+ baseDistDir = path.join(projectRoot, 'dist');
460
+ }
461
+
462
+ // Two output sub-directories: server/ (with <base href="/">) and local/ (relative paths)
463
+ const serverDir = path.join(baseDistDir, 'server');
464
+ const localDir = path.join(baseDistDir, 'local');
465
+
466
+ console.log(`\n zQuery App Bundler`);
467
+ console.log(` Entry: ${entryRel}`);
468
+ console.log(` Output: ${path.relative(projectRoot, baseDistDir)}/server/ & local/`);
469
+ console.log(` Library: embedded`);
470
+ console.log(` HTML: ${htmlFile || 'not found (no index.html detected)'}`);
471
+ console.log('');
472
+
473
+ function doBuild() {
474
+ const start = Date.now();
475
+
476
+ if (!fs.existsSync(serverDir)) fs.mkdirSync(serverDir, { recursive: true });
477
+ if (!fs.existsSync(localDir)) fs.mkdirSync(localDir, { recursive: true });
478
+
479
+ // Walk the import graph
480
+ const files = walkImportGraph(entry);
481
+ console.log(` Resolved ${files.length} module(s):`);
482
+ files.forEach(f => console.log(` • ${path.relative(projectRoot, f)}`));
483
+
484
+ // Build concatenated source
485
+ const sections = files.map(file => {
486
+ let code = fs.readFileSync(file, 'utf-8');
487
+ code = stripModuleSyntax(code);
488
+ code = replaceImportMeta(code, file, projectRoot);
489
+ const rel = path.relative(projectRoot, file);
490
+ return `// --- ${rel} ${'—'.repeat(Math.max(1, 60 - rel.length))}\n${code.trim()}`;
491
+ });
492
+
493
+ // Embed zquery.min.js — always included
494
+ let libSection = '';
495
+ {
496
+ const pkgSrcDir = path.join(__dirname, 'src');
497
+ const pkgMinFile = path.join(__dirname, 'dist', 'zquery.min.js');
498
+
499
+ // Always rebuild the library from source when running from the repo/package
500
+ // so that dist/ stays current with the latest source changes.
501
+ if (fs.existsSync(pkgSrcDir) && fs.existsSync(path.join(__dirname, 'index.js'))) {
502
+ console.log(`\n Building library from source...`);
503
+ const prevCwd = process.cwd();
504
+ try {
505
+ process.chdir(__dirname);
506
+ buildLibrary();
507
+ } finally {
508
+ process.chdir(prevCwd);
509
+ }
510
+ }
511
+
512
+ // Now look for the library in common locations
513
+ const htmlDir = htmlAbs ? path.dirname(htmlAbs) : null;
514
+ const libCandidates = [
515
+ // Prefer the freshly-built package dist
516
+ path.join(__dirname, 'dist/zquery.min.js'),
517
+ // Then check project-local locations
518
+ htmlDir && path.join(htmlDir, 'scripts/vendor/zquery.min.js'),
519
+ htmlDir && path.join(htmlDir, 'vendor/zquery.min.js'),
520
+ path.join(projectRoot, 'scripts/vendor/zquery.min.js'),
521
+ path.join(projectRoot, 'vendor/zquery.min.js'),
522
+ path.join(projectRoot, 'lib/zquery.min.js'),
523
+ path.join(projectRoot, 'dist/zquery.min.js'),
524
+ path.join(projectRoot, 'zquery.min.js'),
525
+ ].filter(Boolean);
526
+ const libPath = libCandidates.find(p => fs.existsSync(p));
527
+
528
+ if (libPath) {
529
+ const libBytes = fs.statSync(libPath).size;
530
+ libSection = `// --- zquery.min.js (library) ${'—'.repeat(34)}\n${fs.readFileSync(libPath, 'utf-8').trim()}\n\n`
531
+ + `// --- Build-time metadata ————————————————————————————\nif(typeof $!=="undefined"){$.meta=Object.assign($.meta||{},{libSize:${libBytes}});}\n\n`;
532
+ console.log(` Embedded library from ${path.relative(projectRoot, libPath)} (${(libBytes / 1024).toFixed(1)} KB)`);
533
+ } else {
534
+ console.warn(`\n ⚠ Could not find zquery.min.js anywhere`);
535
+ console.warn(` Place zquery.min.js in scripts/vendor/, vendor/, lib/, or dist/`);
536
+ }
537
+ }
538
+
539
+ const banner = `/**\n * App bundle — built by zQuery CLI\n * Entry: ${entryRel}\n * ${new Date().toISOString()}\n */`;
540
+
541
+ // Scan for external resources (pages, templateUrl, styleUrl) and inline them
542
+ const inlineMap = collectInlineResources(files, projectRoot);
543
+ let inlineSection = '';
544
+ if (Object.keys(inlineMap).length > 0) {
545
+ const entries = Object.entries(inlineMap).map(([key, content]) => {
546
+ // Escape for embedding in a JS string literal
547
+ const escaped = content
548
+ .replace(/\\/g, '\\\\')
549
+ .replace(/'/g, "\\'")
550
+ .replace(/\n/g, '\\n')
551
+ .replace(/\r/g, '');
552
+ return ` '${key}': '${escaped}'`;
553
+ });
554
+ inlineSection = `// --- Inlined resources (file:// support) ${'—'.repeat(20)}\nwindow.__zqInline = {\n${entries.join(',\n')}\n};\n\n`;
555
+ console.log(`\n Inlined ${Object.keys(inlineMap).length} external resource(s)`);
556
+ }
557
+
558
+ const bundle = `${banner}\n(function() {\n 'use strict';\n\n${libSection}${inlineSection}${sections.join('\n\n')}\n\n})();\n`;
559
+
560
+ // Content-hashed output filenames (z-<name>.<hash>.js)
561
+ const contentHash = crypto.createHash('sha256').update(bundle).digest('hex').slice(0, 8);
562
+ const bundleBase = `z-${entryName}.${contentHash}.js`;
563
+ const minBase = `z-${entryName}.${contentHash}.min.js`;
564
+
565
+ // Remove previous hashed builds from both output directories
566
+ const escName = entryName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
567
+ const cleanRe = new RegExp(`^z-${escName}\\.[a-f0-9]{8}\\.(?:min\\.)?js$`);
568
+ for (const dir of [serverDir, localDir]) {
569
+ if (fs.existsSync(dir)) {
570
+ for (const f of fs.readdirSync(dir)) {
571
+ if (cleanRe.test(f)) fs.unlinkSync(path.join(dir, f));
572
+ }
573
+ }
574
+ }
575
+
576
+ // Write bundle into server/ (canonical), then copy to local/
577
+ const bundleFile = path.join(serverDir, bundleBase);
578
+ const minFile = path.join(serverDir, minBase);
579
+ fs.writeFileSync(bundleFile, bundle, 'utf-8');
580
+ fs.writeFileSync(minFile, minify(bundle, banner), 'utf-8');
581
+ fs.copyFileSync(bundleFile, path.join(localDir, bundleBase));
582
+ fs.copyFileSync(minFile, path.join(localDir, minBase));
583
+
584
+ console.log(`\n ✓ ${bundleBase} (${sizeKB(fs.readFileSync(bundleFile))} KB)`);
585
+ console.log(` ✓ ${minBase} (${sizeKB(fs.readFileSync(minFile))} KB)`);
586
+
587
+ // Rewrite index.html → two variants (server/ and local/)
588
+ if (htmlFile) {
589
+ const bundledFileSet = new Set(files);
590
+ rewriteHtml(projectRoot, htmlFile, bundleFile, true, bundledFileSet, serverDir, localDir);
591
+ }
592
+
593
+ const elapsed = Date.now() - start;
594
+ console.log(` Done in ${elapsed}ms\n`);
595
+ }
596
+
597
+ doBuild();
598
+
599
+ // Watch mode
600
+ if (watchMode) {
601
+ const watchDirs = new Set();
602
+ const files = walkImportGraph(entry);
603
+ files.forEach(f => watchDirs.add(path.dirname(f)));
604
+
605
+ console.log(' Watching for changes...\n');
606
+ let debounceTimer;
607
+ for (const dir of watchDirs) {
608
+ fs.watch(dir, { recursive: true }, (_, filename) => {
609
+ if (!filename || !filename.endsWith('.js')) return;
610
+ clearTimeout(debounceTimer);
611
+ debounceTimer = setTimeout(() => {
612
+ console.log(` Changed: ${filename} — rebuilding...`);
613
+ try { doBuild(); } catch (e) { console.error(` ✗ ${e.message}`); }
614
+ }, 200);
615
+ });
616
+ }
617
+ }
618
+ }
619
+
620
+ /**
621
+ * Recursively copy a directory.
622
+ */
623
+ function copyDirSync(src, dest) {
624
+ if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
625
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
626
+ const srcPath = path.join(src, entry.name);
627
+ const destPath = path.join(dest, entry.name);
628
+ if (entry.isDirectory()) {
629
+ copyDirSync(srcPath, destPath);
630
+ } else {
631
+ fs.copyFileSync(srcPath, destPath);
632
+ }
633
+ }
634
+ }
635
+
636
+ /**
637
+ * Rewrite an HTML file to replace the module <script> with the bundle.
638
+ * Copies all referenced assets into both server/ and local/ dist dirs,
639
+ * then writes two index.html variants:
640
+ * server/index.html — has <base href="/"> for SPA deep-route support
641
+ * local/index.html — no <base>, relative paths for file:// access
642
+ *
643
+ * Both are fully static HTML with no dynamic loading.
644
+ */
645
+ function rewriteHtml(projectRoot, htmlRelPath, bundleFile, includeLib, bundledFiles, serverDir, localDir) {
646
+ const htmlPath = path.resolve(projectRoot, htmlRelPath);
647
+ if (!fs.existsSync(htmlPath)) {
648
+ console.warn(` ⚠ HTML file not found: ${htmlRelPath}`);
649
+ return;
650
+ }
651
+
652
+ const htmlDir = path.dirname(htmlPath);
653
+ let html = fs.readFileSync(htmlPath, 'utf-8');
654
+
655
+ // Collect all asset references from the HTML (src=, href= on link/script/img)
656
+ const assetRe = /(?:<(?:link|script|img)[^>]*?\s(?:src|href)\s*=\s*["'])([^"']+)["']/gi;
657
+ const assets = new Set();
658
+ let m;
659
+ while ((m = assetRe.exec(html)) !== null) {
660
+ const ref = m[1];
661
+ if (ref.startsWith('http') || ref.startsWith('//') || ref.startsWith('data:') || ref.startsWith('#')) continue;
662
+ const refAbs = path.resolve(htmlDir, ref);
663
+ if (bundledFiles && bundledFiles.has(refAbs)) continue;
664
+ if (includeLib && /zquery(?:\.min)?\.js$/i.test(ref)) continue;
665
+ assets.add(ref);
666
+ }
667
+
668
+ // Copy each referenced asset into BOTH dist dirs, preserving directory structure
669
+ let copiedCount = 0;
670
+ for (const asset of assets) {
671
+ const srcFile = path.resolve(htmlDir, asset);
672
+ if (!fs.existsSync(srcFile)) continue;
673
+
674
+ for (const distDir of [serverDir, localDir]) {
675
+ const destFile = path.join(distDir, asset);
676
+ const destDir = path.dirname(destFile);
677
+ if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
678
+ fs.copyFileSync(srcFile, destFile);
679
+ }
680
+ copiedCount++;
681
+ }
682
+
683
+ // Also copy any CSS-referenced assets (fonts, images in url() etc.)
684
+ for (const asset of assets) {
685
+ const srcFile = path.resolve(htmlDir, asset);
686
+ if (!fs.existsSync(srcFile) || !asset.endsWith('.css')) continue;
687
+
688
+ const cssContent = fs.readFileSync(srcFile, 'utf-8');
689
+ const urlRe = /url\(\s*["']?([^"')]+?)["']?\s*\)/g;
690
+ let cm;
691
+ while ((cm = urlRe.exec(cssContent)) !== null) {
692
+ const ref = cm[1];
693
+ if (ref.startsWith('data:') || ref.startsWith('http') || ref.startsWith('//')) continue;
694
+ const cssSrcDir = path.dirname(srcFile);
695
+ const assetSrc = path.resolve(cssSrcDir, ref);
696
+ if (!fs.existsSync(assetSrc)) continue;
697
+
698
+ for (const distDir of [serverDir, localDir]) {
699
+ const cssDestDir = path.dirname(path.join(distDir, asset));
700
+ const assetDest = path.resolve(cssDestDir, ref);
701
+ if (!fs.existsSync(assetDest)) {
702
+ const dir = path.dirname(assetDest);
703
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
704
+ fs.copyFileSync(assetSrc, assetDest);
705
+ }
706
+ }
707
+ copiedCount++;
708
+ }
709
+ }
710
+
711
+ // Make the bundle filename relative (same name in both dirs)
712
+ const bundleRel = path.relative(serverDir, bundleFile).replace(/\\/g, '/');
713
+
714
+ // Replace <script type="module" src="..."> with the bundle (defer)
715
+ html = html.replace(
716
+ /<script\s+type\s*=\s*["']module["']\s+src\s*=\s*["'][^"']+["']\s*>\s*<\/script>/gi,
717
+ `<script defer src="${bundleRel}"></script>`
718
+ );
719
+
720
+ // If library is embedded, remove the standalone zquery script tag
721
+ if (includeLib) {
722
+ html = html.replace(
723
+ /\s*<script\s+src\s*=\s*["'][^"']*zquery(?:\.min)?\.js["']\s*>\s*<\/script>/gi,
724
+ ''
725
+ );
726
+ }
727
+
728
+ // ── server/index.html ──
729
+ // Keep <base href="/"> as-is — the preload scanner sees it, all resources
730
+ // resolve from root, deep-route refreshes work perfectly.
731
+ const serverHtml = html;
732
+
733
+ // ── local/index.html ──
734
+ // Remove <base href="/"> so relative paths resolve from the HTML file's
735
+ // directory — correct for file:// with zero console errors.
736
+ const localHtml = html.replace(/<base\s+href\s*=\s*["']\/["'][^>]*>\s*\n?\s*/i, '');
737
+
738
+ // Write both
739
+ const htmlName = path.basename(htmlRelPath);
740
+ fs.writeFileSync(path.join(serverDir, htmlName), serverHtml, 'utf-8');
741
+ fs.writeFileSync(path.join(localDir, htmlName), localHtml, 'utf-8');
742
+ console.log(` ✓ server/${htmlName} (with <base href="/">)`);
743
+ console.log(` ✓ local/${htmlName} (relative paths, file:// ready)`);
744
+ console.log(` ✓ Copied ${copiedCount} asset(s) into both dist dirs`);
745
+ }
746
+
747
+
748
+ // ---------------------------------------------------------------------------
749
+ // Help
750
+ // ---------------------------------------------------------------------------
751
+
752
+ function showHelp() {
753
+ console.log(`
754
+ zQuery CLI — build & bundle tool
755
+
756
+ COMMANDS
757
+
758
+ build Build the zQuery library → dist/
759
+ (must be run from the project root where src/ lives)
760
+
761
+ bundle [entry] Bundle app ES modules into a single file
762
+ --out, -o <path> Output directory (default: dist/ next to index.html)
763
+ --html <file> Use a specific HTML file (default: auto-detected)
764
+ --watch, -w Watch source files and rebuild on changes
765
+
766
+ SMART DEFAULTS
767
+
768
+ The bundler works with zero flags for typical projects:
769
+ • Entry is auto-detected from index.html <script type="module" src="...">
770
+ • zquery.min.js is always embedded (auto-built from source if not found)
771
+ • index.html is rewritten for both server and local (file://) use
772
+ • Output goes to dist/server/ and dist/local/ next to the detected index.html
773
+
774
+ OUTPUT
775
+
776
+ The bundler produces two self-contained sub-directories:
777
+
778
+ dist/server/ deploy to your web server
779
+ index.html has <base href="/"> for SPA deep routes
780
+ z-<entry>.<hash>.js readable bundle
781
+ z-<entry>.<hash>.min.js minified bundle
782
+
783
+ dist/local/ open from disk (file://)
784
+ index.html relative paths, no <base> tag
785
+ z-<entry>.<hash>.js same bundle
786
+
787
+ Previous hashed builds are automatically cleaned on each rebuild.
788
+
789
+ DEVELOPMENT
790
+
791
+ npm run serve start a local dev server (zero-http, SPA routing)
792
+ npm run dev watch mode — auto-rebuild bundle on source changes
793
+
794
+ EXAMPLES
795
+
796
+ # Build the library only
797
+ zquery build
798
+
799
+ # Bundle an app — auto-detects everything
800
+ cd my-app && zquery bundle
801
+
802
+ # Point to an entry from a parent directory
803
+ zquery bundle path/to/scripts/app.js
804
+
805
+ # Custom output directory
806
+ zquery bundle -o build/
807
+
808
+ # Watch mode
809
+ zquery bundle --watch
810
+
811
+ The bundler walks the ES module import graph starting from the entry
812
+ file, topologically sorts dependencies, strips import/export syntax,
813
+ and concatenates everything into a single IIFE with content-hashed
814
+ filenames for cache-busting. No dependencies needed — just Node.js.
815
+ `);
816
+ }
817
+
818
+
819
+ // ---------------------------------------------------------------------------
820
+ // Main
821
+ // ---------------------------------------------------------------------------
822
+
823
+ if (!command || command === '--help' || command === '-h' || command === 'help') {
824
+ showHelp();
825
+ } else if (command === 'build') {
826
+ console.log('\n zQuery Library Build\n');
827
+ buildLibrary();
828
+ } else if (command === 'bundle') {
829
+ bundleApp();
830
+ } else {
831
+ console.error(`\n Unknown command: ${command}\n Run "zquery --help" for usage.\n`);
832
+ process.exit(1);
833
+ }