zero-query 0.3.1 → 0.5.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.
@@ -0,0 +1,687 @@
1
+ /**
2
+ * cli/commands/bundle.js — app bundler command
3
+ *
4
+ * Walks the ES module import graph starting from an entry file,
5
+ * strips import/export syntax, concatenates everything into a single
6
+ * IIFE with content-hashed filenames, and rewrites index.html for
7
+ * both server and local (file://) deployment.
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const crypto = require('crypto');
15
+
16
+ const { args, flag, option } = require('../args');
17
+ const { minify, sizeKB } = require('../utils');
18
+ const buildLibrary = require('./build');
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Module graph helpers
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /** Resolve an import specifier relative to the importing file. */
25
+ function resolveImport(specifier, fromFile) {
26
+ if (!specifier.startsWith('.') && !specifier.startsWith('/')) return null;
27
+ let resolved = path.resolve(path.dirname(fromFile), specifier);
28
+ if (!path.extname(resolved) && fs.existsSync(resolved + '.js')) {
29
+ resolved += '.js';
30
+ }
31
+ return resolved;
32
+ }
33
+
34
+ /** Extract import specifiers from a source file. */
35
+ function extractImports(code) {
36
+ const specifiers = [];
37
+ let m;
38
+ const fromRe = /\bfrom\s+['"]([^'"]+)['"]/g;
39
+ while ((m = fromRe.exec(code)) !== null) specifiers.push(m[1]);
40
+ const sideRe = /^\s*import\s+['"]([^'"]+)['"]\s*;?\s*$/gm;
41
+ while ((m = sideRe.exec(code)) !== null) {
42
+ if (!specifiers.includes(m[1])) specifiers.push(m[1]);
43
+ }
44
+ return specifiers;
45
+ }
46
+
47
+ /** Walk the import graph — topological sort (leaves first). */
48
+ function walkImportGraph(entry) {
49
+ const visited = new Set();
50
+ const order = [];
51
+
52
+ function visit(file) {
53
+ const abs = path.resolve(file);
54
+ if (visited.has(abs)) return;
55
+ visited.add(abs);
56
+
57
+ if (!fs.existsSync(abs)) {
58
+ console.warn(` ⚠ Missing file: ${abs}`);
59
+ return;
60
+ }
61
+
62
+ const code = fs.readFileSync(abs, 'utf-8');
63
+ const imports = extractImports(code);
64
+ for (const spec of imports) {
65
+ const resolved = resolveImport(spec, abs);
66
+ if (resolved) visit(resolved);
67
+ }
68
+ order.push(abs);
69
+ }
70
+
71
+ visit(entry);
72
+ return order;
73
+ }
74
+
75
+ /** Strip ES module import/export syntax, keeping declarations. */
76
+ function stripModuleSyntax(code) {
77
+ code = code.replace(/^\s*import\s+[\s\S]*?from\s+['"].*?['"];?\s*$/gm, '');
78
+ code = code.replace(/^\s*import\s+['"].*?['"];?\s*$/gm, '');
79
+ code = code.replace(/^(\s*)export\s+default\s+/gm, '$1');
80
+ code = code.replace(/^(\s*)export\s+(const|let|var|function|class|async\s+function)\s/gm, '$1$2 ');
81
+ code = code.replace(/^\s*export\s*\{[\s\S]*?\};?\s*$/gm, '');
82
+ return code;
83
+ }
84
+
85
+ /** Replace import.meta.url with a runtime equivalent. */
86
+ function replaceImportMeta(code, filePath, projectRoot) {
87
+ if (!code.includes('import.meta')) return code;
88
+ const rel = path.relative(projectRoot, filePath).replace(/\\/g, '/');
89
+ return code.replace(/import\.meta\.url/g, `(new URL('${rel}', document.baseURI).href)`);
90
+ }
91
+
92
+ /**
93
+ * Rewrite templateUrl / styleUrl relative paths to project-relative paths.
94
+ * In a bundled IIFE, caller-base detection returns undefined (all stack
95
+ * frames point to the single bundle file), so relative URLs like
96
+ * 'contacts.html' never resolve to their original directory. By rewriting
97
+ * them to the same project-relative keys used in window.__zqInline, the
98
+ * runtime inline-resource lookup succeeds without a network fetch.
99
+ */
100
+ function rewriteResourceUrls(code, filePath, projectRoot) {
101
+ const fileDir = path.dirname(filePath);
102
+ return code.replace(
103
+ /((?:templateUrl|styleUrl)\s*:\s*)(['"])([^'"]+)\2/g,
104
+ (match, prefix, quote, url) => {
105
+ if (url.startsWith('/') || url.includes('://')) return match;
106
+ const abs = path.resolve(fileDir, url);
107
+ // Only rewrite if the file actually exists — avoids mangling code examples
108
+ if (!fs.existsSync(abs)) return match;
109
+ const rel = path.relative(projectRoot, abs).replace(/\\/g, '/');
110
+ return `${prefix}${quote}${rel}${quote}`;
111
+ }
112
+ );
113
+ }
114
+
115
+ /**
116
+ * Scan bundled source files for external resource references
117
+ * (pages config, templateUrl, styleUrl) and return a map of
118
+ * { relativePath: fileContent } for inlining.
119
+ */
120
+ function collectInlineResources(files, projectRoot) {
121
+ const inlineMap = {};
122
+
123
+ for (const file of files) {
124
+ const code = fs.readFileSync(file, 'utf-8');
125
+ const fileDir = path.dirname(file);
126
+
127
+ // pages: config
128
+ const pagesMatch = code.match(/pages\s*:\s*\{[^}]*dir\s*:\s*['"]([^'"]+)['"]/s);
129
+ if (pagesMatch) {
130
+ const pagesDir = pagesMatch[1];
131
+ const ext = (code.match(/pages\s*:\s*\{[^}]*ext\s*:\s*['"]([^'"]+)['"]/s) || [])[1] || '.html';
132
+ const itemsMatch = code.match(/items\s*:\s*\[([\s\S]*?)\]/);
133
+ if (itemsMatch) {
134
+ const itemsBlock = itemsMatch[1];
135
+ const ids = [];
136
+ let m;
137
+ const strRe = /['"]([^'"]+)['"]/g;
138
+ while ((m = strRe.exec(itemsBlock)) !== null) {
139
+ const before = itemsBlock.substring(Math.max(0, m.index - 20), m.index);
140
+ if (/label\s*:\s*$/.test(before)) continue;
141
+ ids.push(m[1]);
142
+ }
143
+ const absPagesDir = path.join(fileDir, pagesDir);
144
+ for (const id of ids) {
145
+ const pagePath = path.join(absPagesDir, id + ext);
146
+ if (fs.existsSync(pagePath)) {
147
+ const relKey = path.relative(projectRoot, pagePath).replace(/\\/g, '/');
148
+ inlineMap[relKey] = fs.readFileSync(pagePath, 'utf-8');
149
+ }
150
+ }
151
+ }
152
+ }
153
+
154
+ // styleUrl:
155
+ const styleMatch = code.match(/styleUrl\s*:\s*['"]([^'"]+)['"]/);
156
+ if (styleMatch) {
157
+ const stylePath = path.join(fileDir, styleMatch[1]);
158
+ if (fs.existsSync(stylePath)) {
159
+ const relKey = path.relative(projectRoot, stylePath).replace(/\\/g, '/');
160
+ inlineMap[relKey] = fs.readFileSync(stylePath, 'utf-8');
161
+ }
162
+ }
163
+
164
+ // templateUrl:
165
+ const tmplMatch = code.match(/templateUrl\s*:\s*['"]([^'"]+)['"]/);
166
+ if (tmplMatch) {
167
+ const tmplPath = path.join(fileDir, tmplMatch[1]);
168
+ if (fs.existsSync(tmplPath)) {
169
+ const relKey = path.relative(projectRoot, tmplPath).replace(/\\/g, '/');
170
+ inlineMap[relKey] = fs.readFileSync(tmplPath, 'utf-8');
171
+ }
172
+ }
173
+ }
174
+
175
+ return inlineMap;
176
+ }
177
+
178
+ /**
179
+ * Auto-detect the app entry point.
180
+ *
181
+ * Strategy — ordered by precedence (first match wins):
182
+ * 1. HTML discovery: index.html first, then other .html files
183
+ * (root level + one directory deep).
184
+ * 2. Within each HTML file, prefer a module <script> whose src
185
+ * resolves to app.js, then fall back to the first module
186
+ * <script> tag regardless of name.
187
+ * 3. JS file scan: look for $.router first (entry point by design),
188
+ * then $.mount / $.store / mountAll.
189
+ * 4. Convention fallback paths.
190
+ */
191
+ function detectEntry(projectRoot) {
192
+ // Matches all <script type="module" src="…"> tags (global)
193
+ const moduleScriptReG = /<script[^>]+type\s*=\s*["']module["'][^>]+src\s*=\s*["']([^"']+)["']/g;
194
+
195
+ // Collect HTML files: root level + one directory deep
196
+ const htmlFiles = [];
197
+ for (const entry of fs.readdirSync(projectRoot, { withFileTypes: true })) {
198
+ if (entry.isFile() && entry.name.endsWith('.html')) {
199
+ htmlFiles.push(path.join(projectRoot, entry.name));
200
+ } else if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules' && entry.name !== 'dist') {
201
+ const sub = path.join(projectRoot, entry.name);
202
+ try {
203
+ for (const child of fs.readdirSync(sub, { withFileTypes: true })) {
204
+ if (child.isFile() && child.name.endsWith('.html')) {
205
+ htmlFiles.push(path.join(sub, child.name));
206
+ }
207
+ }
208
+ } catch { /* permission error — skip */ }
209
+ }
210
+ }
211
+
212
+ // Sort: index.html first (at any depth), then alphabetical
213
+ htmlFiles.sort((a, b) => {
214
+ const aIsIndex = path.basename(a) === 'index.html' ? 0 : 1;
215
+ const bIsIndex = path.basename(b) === 'index.html' ? 0 : 1;
216
+ if (aIsIndex !== bIsIndex) return aIsIndex - bIsIndex;
217
+ return a.localeCompare(b);
218
+ });
219
+
220
+ // 1. Parse module <script> tags from HTML files (index.html evaluated first).
221
+ // Within each file prefer a src ending in app.js, else the first module tag.
222
+ for (const htmlPath of htmlFiles) {
223
+ const html = fs.readFileSync(htmlPath, 'utf-8');
224
+ const htmlDir = path.dirname(htmlPath);
225
+
226
+ let appJsEntry = null; // src that resolves to app.js
227
+ let firstEntry = null; // first module script src
228
+ let m;
229
+ moduleScriptReG.lastIndex = 0;
230
+ while ((m = moduleScriptReG.exec(html)) !== null) {
231
+ const resolved = path.resolve(htmlDir, m[1]);
232
+ if (!fs.existsSync(resolved)) continue;
233
+ if (!firstEntry) firstEntry = resolved;
234
+ if (path.basename(resolved) === 'app.js') { appJsEntry = resolved; break; }
235
+ }
236
+
237
+ if (appJsEntry) return appJsEntry;
238
+ if (firstEntry) return firstEntry;
239
+ }
240
+
241
+ // 2. Search JS files for entry-point patterns.
242
+ // Pass 1 — $.router (the canonical entry point).
243
+ // Pass 2 — $.mount, $.store, mountAll (component-level, lower confidence).
244
+ const routerRe = /\$\.router\s*\(/;
245
+ const otherRe = /\$\.(mount|store)\s*\(|mountAll\s*\(/;
246
+
247
+ function collectJS(dir, depth = 0) {
248
+ const results = [];
249
+ if (depth > 2) return results;
250
+ try {
251
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
252
+ if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'dist') continue;
253
+ const full = path.join(dir, entry.name);
254
+ if (entry.isFile() && entry.name.endsWith('.js')) {
255
+ results.push(full);
256
+ } else if (entry.isDirectory()) {
257
+ results.push(...collectJS(full, depth + 1));
258
+ }
259
+ }
260
+ } catch { /* skip */ }
261
+ return results;
262
+ }
263
+
264
+ const jsFiles = collectJS(projectRoot);
265
+
266
+ // Pass 1: $.router
267
+ for (const file of jsFiles) {
268
+ const code = fs.readFileSync(file, 'utf-8');
269
+ if (routerRe.test(code)) return file;
270
+ }
271
+ // Pass 2: other entry-point signals
272
+ for (const file of jsFiles) {
273
+ const code = fs.readFileSync(file, 'utf-8');
274
+ if (otherRe.test(code)) return file;
275
+ }
276
+
277
+ // 3. Convention fallbacks
278
+ const fallbacks = ['scripts/app.js', 'src/app.js', 'js/app.js', 'app.js', 'main.js'];
279
+ for (const f of fallbacks) {
280
+ const fp = path.join(projectRoot, f);
281
+ if (fs.existsSync(fp)) return fp;
282
+ }
283
+ return null;
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // HTML rewriting
288
+ // ---------------------------------------------------------------------------
289
+
290
+ /**
291
+ * Rewrite an HTML file to replace the module <script> with the bundle.
292
+ * Produces two variants:
293
+ * server/index.html — <base href="/"> for SPA deep routes
294
+ * local/index.html — relative paths for file:// access
295
+ */
296
+ function rewriteHtml(projectRoot, htmlRelPath, bundleFile, includeLib, bundledFiles, serverDir, localDir) {
297
+ const htmlPath = path.resolve(projectRoot, htmlRelPath);
298
+ if (!fs.existsSync(htmlPath)) {
299
+ console.warn(` ⚠ HTML file not found: ${htmlRelPath}`);
300
+ return;
301
+ }
302
+
303
+ const htmlDir = path.dirname(htmlPath);
304
+ let html = fs.readFileSync(htmlPath, 'utf-8');
305
+
306
+ // Collect asset references from HTML
307
+ const assetRe = /(?:<(?:link|script|img)[^>]*?\s(?:src|href)\s*=\s*["'])([^"']+)["']/gi;
308
+ const assets = new Set();
309
+ let m;
310
+ while ((m = assetRe.exec(html)) !== null) {
311
+ const ref = m[1];
312
+ if (ref.startsWith('http') || ref.startsWith('//') || ref.startsWith('data:') || ref.startsWith('#')) continue;
313
+ const refAbs = path.resolve(htmlDir, ref);
314
+ if (bundledFiles && bundledFiles.has(refAbs)) continue;
315
+ if (includeLib && /zquery(?:\.min)?\.js$/i.test(ref)) continue;
316
+ assets.add(ref);
317
+ }
318
+
319
+ // Copy assets into both dist dirs
320
+ let copiedCount = 0;
321
+ for (const asset of assets) {
322
+ const srcFile = path.resolve(htmlDir, asset);
323
+ if (!fs.existsSync(srcFile)) continue;
324
+ for (const distDir of [serverDir, localDir]) {
325
+ const destFile = path.join(distDir, asset);
326
+ const destDir = path.dirname(destFile);
327
+ if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
328
+ fs.copyFileSync(srcFile, destFile);
329
+ }
330
+ copiedCount++;
331
+ }
332
+
333
+ // Copy CSS-referenced assets (fonts, images in url())
334
+ for (const asset of assets) {
335
+ const srcFile = path.resolve(htmlDir, asset);
336
+ if (!fs.existsSync(srcFile) || !asset.endsWith('.css')) continue;
337
+ const cssContent = fs.readFileSync(srcFile, 'utf-8');
338
+ const urlRe = /url\(\s*["']?([^"')]+?)["']?\s*\)/g;
339
+ let cm;
340
+ while ((cm = urlRe.exec(cssContent)) !== null) {
341
+ const ref = cm[1];
342
+ if (ref.startsWith('data:') || ref.startsWith('http') || ref.startsWith('//')) continue;
343
+ const cssSrcDir = path.dirname(srcFile);
344
+ const assetSrc = path.resolve(cssSrcDir, ref);
345
+ if (!fs.existsSync(assetSrc)) continue;
346
+ for (const distDir of [serverDir, localDir]) {
347
+ const cssDestDir = path.dirname(path.join(distDir, asset));
348
+ const assetDest = path.resolve(cssDestDir, ref);
349
+ if (!fs.existsSync(assetDest)) {
350
+ const dir = path.dirname(assetDest);
351
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
352
+ fs.copyFileSync(assetSrc, assetDest);
353
+ }
354
+ }
355
+ copiedCount++;
356
+ }
357
+ }
358
+
359
+ const bundleRel = path.relative(serverDir, bundleFile).replace(/\\/g, '/');
360
+
361
+ html = html.replace(
362
+ /<script\s+type\s*=\s*["']module["']\s+src\s*=\s*["'][^"']+["']\s*>\s*<\/script>/gi,
363
+ `<script defer src="${bundleRel}"></script>`
364
+ );
365
+
366
+ if (includeLib) {
367
+ html = html.replace(
368
+ /\s*<script\s+src\s*=\s*["'][^"']*zquery(?:\.min)?\.js["']\s*>\s*<\/script>/gi,
369
+ ''
370
+ );
371
+ }
372
+
373
+ const serverHtml = html;
374
+ const localHtml = html.replace(/<base\s+href\s*=\s*["']\/["'][^>]*>\s*\n?\s*/i, '');
375
+
376
+ const htmlName = path.basename(htmlRelPath);
377
+ fs.writeFileSync(path.join(serverDir, htmlName), serverHtml, 'utf-8');
378
+ fs.writeFileSync(path.join(localDir, htmlName), localHtml, 'utf-8');
379
+ console.log(` ✓ server/${htmlName} (with <base href="/">)`);
380
+ console.log(` ✓ local/${htmlName} (relative paths, file:// ready)`);
381
+ console.log(` ✓ Copied ${copiedCount} asset(s) into both dist dirs`);
382
+ }
383
+
384
+ // ---------------------------------------------------------------------------
385
+ // Static asset copying
386
+ // ---------------------------------------------------------------------------
387
+
388
+ /**
389
+ * Copy the entire app directory into both dist/server and dist/local,
390
+ * skipping only build outputs and tooling dirs. This ensures all static
391
+ * assets (icons/, images/, fonts/, styles/, scripts/, manifests, etc.)
392
+ * are available in the built output without maintaining a fragile whitelist.
393
+ */
394
+ function copyStaticAssets(appRoot, serverDir, localDir, bundledFiles) {
395
+ const SKIP_DIRS = new Set(['dist', 'node_modules', '.git', '.vscode', 'scripts']);
396
+
397
+ let copiedCount = 0;
398
+
399
+ function copyEntry(srcPath, relPath) {
400
+ const stat = fs.statSync(srcPath);
401
+ if (stat.isDirectory()) {
402
+ if (SKIP_DIRS.has(path.basename(srcPath))) return;
403
+ for (const child of fs.readdirSync(srcPath)) {
404
+ copyEntry(path.join(srcPath, child), path.join(relPath, child));
405
+ }
406
+ } else {
407
+ if (bundledFiles && bundledFiles.has(path.resolve(srcPath))) return;
408
+ for (const distDir of [serverDir, localDir]) {
409
+ const dest = path.join(distDir, relPath);
410
+ if (fs.existsSync(dest)) continue; // already copied by rewriteHtml
411
+ const dir = path.dirname(dest);
412
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
413
+ fs.copyFileSync(srcPath, dest);
414
+ }
415
+ copiedCount++;
416
+ }
417
+ }
418
+
419
+ for (const entry of fs.readdirSync(appRoot, { withFileTypes: true })) {
420
+ if (entry.isDirectory()) {
421
+ if (SKIP_DIRS.has(entry.name)) continue;
422
+ copyEntry(path.join(appRoot, entry.name), entry.name);
423
+ } else {
424
+ copyEntry(path.join(appRoot, entry.name), entry.name);
425
+ }
426
+ }
427
+
428
+ if (copiedCount > 0) {
429
+ console.log(` \u2713 Copied ${copiedCount} additional static asset(s) into both dist dirs`);
430
+ }
431
+ }
432
+
433
+ // ---------------------------------------------------------------------------
434
+ // Main bundleApp function
435
+ // ---------------------------------------------------------------------------
436
+
437
+ function bundleApp() {
438
+ const projectRoot = process.cwd();
439
+ const minimal = flag('minimal', 'm');
440
+
441
+ // Entry point — positional arg (directory or file) or auto-detection
442
+ let entry = null;
443
+ let targetDir = null;
444
+ for (let i = 1; i < args.length; i++) {
445
+ if (!args[i].startsWith('-') && args[i - 1] !== '-o' && args[i - 1] !== '--out' && args[i - 1] !== '--index') {
446
+ const resolved = path.resolve(projectRoot, args[i]);
447
+ if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
448
+ targetDir = resolved;
449
+ entry = detectEntry(resolved);
450
+ } else {
451
+ entry = resolved;
452
+ }
453
+ break;
454
+ }
455
+ }
456
+ if (!entry) entry = detectEntry(projectRoot);
457
+
458
+ if (!entry || !fs.existsSync(entry)) {
459
+ console.error(`\n \u2717 Could not find entry file.`);
460
+ console.error(` Provide an app directory: zquery bundle my-app/`);
461
+ console.error(` Or pass a direct entry file: zquery bundle my-app/scripts/main.js\n`);
462
+ process.exit(1);
463
+ }
464
+
465
+ const outPath = option('out', 'o', null);
466
+
467
+ // Auto-detect HTML file
468
+ let htmlFile = option('index', 'i', null);
469
+ let htmlAbs = htmlFile ? path.resolve(projectRoot, htmlFile) : null;
470
+ if (!htmlFile) {
471
+ // Strategy: first look for index.html walking up from entry, then
472
+ // scan for any .html that references the entry via a module script tag.
473
+ const htmlCandidates = [];
474
+ let entryDir = path.dirname(entry);
475
+ while (entryDir.length >= projectRoot.length) {
476
+ htmlCandidates.push(path.join(entryDir, 'index.html'));
477
+ const parent = path.dirname(entryDir);
478
+ if (parent === entryDir) break;
479
+ entryDir = parent;
480
+ }
481
+ htmlCandidates.push(path.join(projectRoot, 'index.html'));
482
+ htmlCandidates.push(path.join(projectRoot, 'public/index.html'));
483
+ for (const candidate of htmlCandidates) {
484
+ if (fs.existsSync(candidate)) {
485
+ htmlAbs = candidate;
486
+ htmlFile = path.relative(projectRoot, candidate);
487
+ break;
488
+ }
489
+ }
490
+
491
+ // If no index.html found, scan for any .html file that references
492
+ // the entry point (supports home.html, app.html, etc.)
493
+ if (!htmlAbs) {
494
+ const searchRoot = targetDir || projectRoot;
495
+ const htmlScan = [];
496
+ for (const e of fs.readdirSync(searchRoot, { withFileTypes: true })) {
497
+ if (e.isFile() && e.name.endsWith('.html')) {
498
+ htmlScan.push(path.join(searchRoot, e.name));
499
+ } else if (e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules' && e.name !== 'dist') {
500
+ try {
501
+ for (const child of fs.readdirSync(path.join(searchRoot, e.name), { withFileTypes: true })) {
502
+ if (child.isFile() && child.name.endsWith('.html')) {
503
+ htmlScan.push(path.join(searchRoot, e.name, child.name));
504
+ }
505
+ }
506
+ } catch { /* skip */ }
507
+ }
508
+ }
509
+ // Prefer the HTML file that references our entry via a module script
510
+ const moduleScriptRe = /<script[^>]+type\s*=\s*["']module["'][^>]+src\s*=\s*["']([^"']+)["']/g;
511
+ for (const hp of htmlScan) {
512
+ const content = fs.readFileSync(hp, 'utf-8');
513
+ let m;
514
+ moduleScriptRe.lastIndex = 0;
515
+ while ((m = moduleScriptRe.exec(content)) !== null) {
516
+ const resolved = path.resolve(path.dirname(hp), m[1]);
517
+ if (resolved === path.resolve(entry)) {
518
+ htmlAbs = hp;
519
+ htmlFile = path.relative(projectRoot, hp);
520
+ break;
521
+ }
522
+ }
523
+ if (htmlAbs) break;
524
+ }
525
+ // Last resort: use the first .html found
526
+ if (!htmlAbs && htmlScan.length > 0) {
527
+ htmlAbs = htmlScan[0];
528
+ htmlFile = path.relative(projectRoot, htmlScan[0]);
529
+ }
530
+ }
531
+ }
532
+
533
+ // Output directory
534
+ const entryRel = path.relative(projectRoot, entry);
535
+ const entryName = path.basename(entry, '.js');
536
+ let baseDistDir;
537
+ if (outPath) {
538
+ const resolved = path.resolve(projectRoot, outPath);
539
+ if (outPath.endsWith('/') || outPath.endsWith('\\') || !path.extname(outPath) ||
540
+ (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory())) {
541
+ baseDistDir = resolved;
542
+ } else {
543
+ baseDistDir = path.dirname(resolved);
544
+ }
545
+ } else if (htmlAbs) {
546
+ baseDistDir = path.join(path.dirname(htmlAbs), 'dist');
547
+ } else {
548
+ baseDistDir = path.join(projectRoot, 'dist');
549
+ }
550
+
551
+ const serverDir = path.join(baseDistDir, 'server');
552
+ const localDir = path.join(baseDistDir, 'local');
553
+
554
+ console.log(`\n zQuery App Bundler`);
555
+ console.log(` Entry: ${entryRel}`);
556
+ console.log(` Output: ${path.relative(projectRoot, baseDistDir)}/server/ & local/`);
557
+ console.log(` Library: embedded`);
558
+ console.log(` HTML: ${htmlFile || 'not found (no HTML detected)'}`);
559
+ if (minimal) console.log(` Mode: minimal (HTML + bundle only)`);
560
+ console.log('');
561
+
562
+ // ------ doBuild (inlined) ------
563
+ const start = Date.now();
564
+
565
+ if (!fs.existsSync(serverDir)) fs.mkdirSync(serverDir, { recursive: true });
566
+ if (!fs.existsSync(localDir)) fs.mkdirSync(localDir, { recursive: true });
567
+
568
+ const files = walkImportGraph(entry);
569
+ console.log(` Resolved ${files.length} module(s):`);
570
+ files.forEach(f => console.log(` • ${path.relative(projectRoot, f)}`));
571
+
572
+ const sections = files.map(file => {
573
+ let code = fs.readFileSync(file, 'utf-8');
574
+ code = stripModuleSyntax(code);
575
+ code = replaceImportMeta(code, file, projectRoot);
576
+ code = rewriteResourceUrls(code, file, projectRoot);
577
+ const rel = path.relative(projectRoot, file);
578
+ return `// --- ${rel} ${'—'.repeat(Math.max(1, 60 - rel.length))}\n${code.trim()}`;
579
+ });
580
+
581
+ // Embed zquery.min.js
582
+ let libSection = '';
583
+ {
584
+ // __dirname is cli/commands/, so the package root is two levels up
585
+ const pkgRoot = path.resolve(__dirname, '..', '..');
586
+ const pkgSrcDir = path.join(pkgRoot, 'src');
587
+ const pkgMinFile = path.join(pkgRoot, 'dist', 'zquery.min.js');
588
+
589
+ if (fs.existsSync(pkgSrcDir) && fs.existsSync(path.join(pkgRoot, 'index.js'))) {
590
+ console.log(`\n Building library from source...`);
591
+ const prevCwd = process.cwd();
592
+ try {
593
+ process.chdir(pkgRoot);
594
+ buildLibrary();
595
+ } finally {
596
+ process.chdir(prevCwd);
597
+ }
598
+ }
599
+
600
+ const htmlDir = htmlAbs ? path.dirname(htmlAbs) : null;
601
+ const libCandidates = [
602
+ path.join(pkgRoot, 'dist/zquery.min.js'),
603
+ htmlDir && path.join(htmlDir, 'scripts/vendor/zquery.min.js'),
604
+ htmlDir && path.join(htmlDir, 'vendor/zquery.min.js'),
605
+ path.join(projectRoot, 'scripts/vendor/zquery.min.js'),
606
+ path.join(projectRoot, 'vendor/zquery.min.js'),
607
+ path.join(projectRoot, 'lib/zquery.min.js'),
608
+ path.join(projectRoot, 'dist/zquery.min.js'),
609
+ path.join(projectRoot, 'zquery.min.js'),
610
+ ].filter(Boolean);
611
+ const libPath = libCandidates.find(p => fs.existsSync(p));
612
+
613
+ if (libPath) {
614
+ 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`;
617
+ console.log(` Embedded library from ${path.relative(projectRoot, libPath)} (${(libBytes / 1024).toFixed(1)} KB)`);
618
+ } else {
619
+ console.warn(`\n ⚠ Could not find zquery.min.js anywhere`);
620
+ console.warn(` Place zquery.min.js in scripts/vendor/, vendor/, lib/, or dist/`);
621
+ }
622
+ }
623
+
624
+ const banner = `/**\n * App bundle — built by zQuery CLI\n * Entry: ${entryRel}\n * ${new Date().toISOString()}\n */`;
625
+
626
+ // Inline resources
627
+ const inlineMap = collectInlineResources(files, projectRoot);
628
+ let inlineSection = '';
629
+ if (Object.keys(inlineMap).length > 0) {
630
+ const entries = Object.entries(inlineMap).map(([key, content]) => {
631
+ const escaped = content
632
+ .replace(/\\/g, '\\\\')
633
+ .replace(/'/g, "\\'")
634
+ .replace(/\n/g, '\\n')
635
+ .replace(/\r/g, '');
636
+ return ` '${key}': '${escaped}'`;
637
+ });
638
+ inlineSection = `// --- Inlined resources (file:// support) ${'—'.repeat(20)}\nwindow.__zqInline = {\n${entries.join(',\n')}\n};\n\n`;
639
+ console.log(`\n Inlined ${Object.keys(inlineMap).length} external resource(s)`);
640
+ }
641
+
642
+ const bundle = `${banner}\n(function() {\n 'use strict';\n\n${libSection}${inlineSection}${sections.join('\n\n')}\n\n})();\n`;
643
+
644
+ // Content-hashed filenames
645
+ const contentHash = crypto.createHash('sha256').update(bundle).digest('hex').slice(0, 8);
646
+ const bundleBase = `z-${entryName}.${contentHash}.js`;
647
+ const minBase = `z-${entryName}.${contentHash}.min.js`;
648
+
649
+ // Clean previous builds
650
+ const escName = entryName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
651
+ const cleanRe = new RegExp(`^z-${escName}\\.[a-f0-9]{8}\\.(?:min\\.)?js$`);
652
+ for (const dir of [serverDir, localDir]) {
653
+ if (fs.existsSync(dir)) {
654
+ for (const f of fs.readdirSync(dir)) {
655
+ if (cleanRe.test(f)) fs.unlinkSync(path.join(dir, f));
656
+ }
657
+ }
658
+ }
659
+
660
+ // Write bundles
661
+ const bundleFile = path.join(serverDir, bundleBase);
662
+ const minFile = path.join(serverDir, minBase);
663
+ fs.writeFileSync(bundleFile, bundle, 'utf-8');
664
+ fs.writeFileSync(minFile, minify(bundle, banner), 'utf-8');
665
+ fs.copyFileSync(bundleFile, path.join(localDir, bundleBase));
666
+ fs.copyFileSync(minFile, path.join(localDir, minBase));
667
+
668
+ console.log(`\n ✓ ${bundleBase} (${sizeKB(fs.readFileSync(bundleFile))} KB)`);
669
+ console.log(` ✓ ${minBase} (${sizeKB(fs.readFileSync(minFile))} KB)`);
670
+
671
+ // Rewrite HTML (use full bundle — minified version mangles template literal whitespace)
672
+ const bundledFileSet = new Set(files);
673
+ if (htmlFile) {
674
+ rewriteHtml(projectRoot, htmlFile, bundleFile, true, bundledFileSet, serverDir, localDir);
675
+ }
676
+
677
+ // Copy static asset directories (icons/, images/, fonts/, etc.)
678
+ if (!minimal) {
679
+ const appRoot = htmlAbs ? path.dirname(htmlAbs) : (targetDir || projectRoot);
680
+ copyStaticAssets(appRoot, serverDir, localDir, bundledFileSet);
681
+ }
682
+
683
+ const elapsed = Date.now() - start;
684
+ console.log(` Done in ${elapsed}ms\n`);
685
+ }
686
+
687
+ module.exports = bundleApp;