zero-query 1.0.9 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +2 -0
  3. package/cli/args.js +33 -33
  4. package/cli/commands/build-api.js +443 -0
  5. package/cli/commands/build.js +254 -216
  6. package/cli/commands/bundle.js +1228 -1183
  7. package/cli/commands/create.js +137 -121
  8. package/cli/commands/dev/devtools/index.js +56 -56
  9. package/cli/commands/dev/devtools/js/components.js +49 -49
  10. package/cli/commands/dev/devtools/js/core.js +423 -423
  11. package/cli/commands/dev/devtools/js/elements.js +421 -421
  12. package/cli/commands/dev/devtools/js/network.js +166 -166
  13. package/cli/commands/dev/devtools/js/performance.js +73 -73
  14. package/cli/commands/dev/devtools/js/router.js +105 -105
  15. package/cli/commands/dev/devtools/js/source.js +132 -132
  16. package/cli/commands/dev/devtools/js/stats.js +35 -35
  17. package/cli/commands/dev/devtools/js/tabs.js +79 -79
  18. package/cli/commands/dev/devtools/panel.html +95 -95
  19. package/cli/commands/dev/devtools/styles.css +244 -244
  20. package/cli/commands/dev/index.js +107 -107
  21. package/cli/commands/dev/logger.js +75 -75
  22. package/cli/commands/dev/overlay.js +858 -858
  23. package/cli/commands/dev/server.js +220 -167
  24. package/cli/commands/dev/validator.js +94 -94
  25. package/cli/commands/dev/watcher.js +172 -172
  26. package/cli/help.js +114 -112
  27. package/cli/index.js +52 -52
  28. package/cli/scaffold/default/LICENSE +21 -21
  29. package/cli/scaffold/default/app/app.js +207 -207
  30. package/cli/scaffold/default/app/components/about.js +201 -201
  31. package/cli/scaffold/default/app/components/api-demo.js +143 -143
  32. package/cli/scaffold/default/app/components/contact-card.js +231 -231
  33. package/cli/scaffold/default/app/components/contacts/contacts.css +706 -706
  34. package/cli/scaffold/default/app/components/contacts/contacts.html +200 -200
  35. package/cli/scaffold/default/app/components/contacts/contacts.js +196 -196
  36. package/cli/scaffold/default/app/components/counter.js +127 -127
  37. package/cli/scaffold/default/app/components/home.js +249 -249
  38. package/cli/scaffold/default/app/components/not-found.js +16 -16
  39. package/cli/scaffold/default/app/components/playground/playground.css +115 -115
  40. package/cli/scaffold/default/app/components/playground/playground.html +161 -161
  41. package/cli/scaffold/default/app/components/playground/playground.js +116 -116
  42. package/cli/scaffold/default/app/components/todos.js +225 -225
  43. package/cli/scaffold/default/app/components/toolkit/toolkit.css +97 -97
  44. package/cli/scaffold/default/app/components/toolkit/toolkit.html +146 -146
  45. package/cli/scaffold/default/app/components/toolkit/toolkit.js +280 -280
  46. package/cli/scaffold/default/app/routes.js +15 -15
  47. package/cli/scaffold/default/app/store.js +101 -101
  48. package/cli/scaffold/default/global.css +552 -552
  49. package/cli/scaffold/default/index.html +99 -99
  50. package/cli/scaffold/minimal/app/app.js +85 -85
  51. package/cli/scaffold/minimal/app/components/about.js +68 -68
  52. package/cli/scaffold/minimal/app/components/counter.js +122 -122
  53. package/cli/scaffold/minimal/app/components/home.js +68 -68
  54. package/cli/scaffold/minimal/app/components/not-found.js +16 -16
  55. package/cli/scaffold/minimal/app/routes.js +9 -9
  56. package/cli/scaffold/minimal/app/store.js +36 -36
  57. package/cli/scaffold/minimal/global.css +300 -300
  58. package/cli/scaffold/minimal/index.html +44 -44
  59. package/cli/scaffold/ssr/app/app.js +41 -41
  60. package/cli/scaffold/ssr/app/components/about.js +55 -55
  61. package/cli/scaffold/ssr/app/components/blog/index.js +65 -65
  62. package/cli/scaffold/ssr/app/components/blog/post.js +86 -86
  63. package/cli/scaffold/ssr/app/components/home.js +37 -37
  64. package/cli/scaffold/ssr/app/components/not-found.js +15 -15
  65. package/cli/scaffold/ssr/app/routes.js +8 -8
  66. package/cli/scaffold/ssr/global.css +228 -228
  67. package/cli/scaffold/ssr/index.html +37 -37
  68. package/cli/scaffold/ssr/package.json +8 -8
  69. package/cli/scaffold/ssr/server/data/posts.js +144 -144
  70. package/cli/scaffold/ssr/server/index.js +213 -213
  71. package/cli/scaffold/webrtc/app/app.js +11 -0
  72. package/cli/scaffold/webrtc/app/components/video-room.js +295 -0
  73. package/cli/scaffold/webrtc/app/lib/room.js +252 -0
  74. package/cli/scaffold/webrtc/assets/.gitkeep +0 -0
  75. package/cli/scaffold/webrtc/global.css +250 -0
  76. package/cli/scaffold/webrtc/index.html +21 -0
  77. package/cli/utils.js +305 -287
  78. package/dist/API.md +7264 -0
  79. package/dist/zquery.dist.zip +0 -0
  80. package/dist/zquery.js +10313 -6252
  81. package/dist/zquery.min.js +8 -601
  82. package/index.d.ts +570 -365
  83. package/index.js +311 -232
  84. package/package.json +76 -69
  85. package/src/component.js +1709 -1454
  86. package/src/core.js +921 -921
  87. package/src/diff.js +497 -497
  88. package/src/errors.js +209 -209
  89. package/src/expression.js +922 -922
  90. package/src/http.js +242 -242
  91. package/src/package.json +1 -1
  92. package/src/reactive.js +255 -254
  93. package/src/router.js +843 -773
  94. package/src/ssr.js +418 -418
  95. package/src/store.js +318 -272
  96. package/src/utils.js +515 -515
  97. package/src/webrtc/e2ee.js +351 -0
  98. package/src/webrtc/errors.js +116 -0
  99. package/src/webrtc/ice.js +301 -0
  100. package/src/webrtc/index.js +131 -0
  101. package/src/webrtc/joinToken.js +119 -0
  102. package/src/webrtc/observe.js +172 -0
  103. package/src/webrtc/peer.js +351 -0
  104. package/src/webrtc/reactive.js +268 -0
  105. package/src/webrtc/room.js +625 -0
  106. package/src/webrtc/sdp.js +302 -0
  107. package/src/webrtc/sfu/index.js +43 -0
  108. package/src/webrtc/sfu/livekit.js +131 -0
  109. package/src/webrtc/sfu/mediasoup.js +150 -0
  110. package/src/webrtc/signaling.js +373 -0
  111. package/src/webrtc/turn.js +237 -0
  112. package/tests/_helpers/webrtcFakes.js +289 -0
  113. package/tests/audit.test.js +4158 -4158
  114. package/tests/cli.test.js +1136 -1023
  115. package/tests/compare.test.js +497 -0
  116. package/tests/component.test.js +3969 -3938
  117. package/tests/core.test.js +1910 -1910
  118. package/tests/dev-server.test.js +489 -0
  119. package/tests/diff.test.js +1416 -1416
  120. package/tests/docs.test.js +1664 -0
  121. package/tests/electron-features.test.js +864 -0
  122. package/tests/errors.test.js +619 -619
  123. package/tests/expression.test.js +1056 -1056
  124. package/tests/http.test.js +648 -648
  125. package/tests/reactive.test.js +819 -819
  126. package/tests/router.test.js +2327 -2327
  127. package/tests/ssr.test.js +870 -870
  128. package/tests/store.test.js +830 -830
  129. package/tests/test-minifier.js +153 -153
  130. package/tests/test-ssr.js +27 -27
  131. package/tests/utils.test.js +1377 -1377
  132. package/tests/webrtc/e2ee.test.js +283 -0
  133. package/tests/webrtc/ice.test.js +202 -0
  134. package/tests/webrtc/joinToken.test.js +89 -0
  135. package/tests/webrtc/observe.test.js +111 -0
  136. package/tests/webrtc/peer.test.js +373 -0
  137. package/tests/webrtc/reactive.test.js +235 -0
  138. package/tests/webrtc/room.test.js +406 -0
  139. package/tests/webrtc/sdp.test.js +151 -0
  140. package/tests/webrtc/sfu-livekit.test.js +119 -0
  141. package/tests/webrtc/sfu.test.js +160 -0
  142. package/tests/webrtc/signaling.test.js +251 -0
  143. package/tests/webrtc/turn.test.js +256 -0
  144. package/types/collection.d.ts +383 -383
  145. package/types/component.d.ts +186 -186
  146. package/types/errors.d.ts +135 -135
  147. package/types/http.d.ts +92 -92
  148. package/types/misc.d.ts +201 -201
  149. package/types/reactive.d.ts +98 -98
  150. package/types/router.d.ts +190 -190
  151. package/types/ssr.d.ts +102 -102
  152. package/types/store.d.ts +146 -145
  153. package/types/utils.d.ts +245 -245
  154. package/types/webrtc.d.ts +653 -0
@@ -1,1183 +1,1228 @@
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
- // 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;
29
- let resolved = path.resolve(path.dirname(fromFile), specifier);
30
- if (!path.extname(resolved) && fs.existsSync(resolved + '.js')) {
31
- resolved += '.js';
32
- }
33
- return resolved;
34
- }
35
-
36
- /** Extract import specifiers from a source file. */
37
- function extractImports(code) {
38
- // Only scan the import preamble (before the first top-level `export`)
39
- // so that code examples inside exported template strings are not
40
- // mistaken for real imports.
41
- const exportStart = code.search(/^export\b/m);
42
- const preamble = exportStart > -1 ? code.slice(0, exportStart) : code;
43
-
44
- const specifiers = [];
45
- let m;
46
- const fromRe = /\bfrom\s+['"]([^'"]+)['"]/g;
47
- while ((m = fromRe.exec(preamble)) !== null) specifiers.push(m[1]);
48
- const sideRe = /^\s*import\s+['"]([^'"]+)['"]\s*;?\s*$/gm;
49
- while ((m = sideRe.exec(preamble)) !== null) {
50
- if (!specifiers.includes(m[1])) specifiers.push(m[1]);
51
- }
52
-
53
- // Also capture re-exports anywhere in the file:
54
- // export { x } from '...' export * from '...'
55
- const reExportRe = /^\s*export\s+(?:\{[^}]*\}|\*)\s*from\s+['"]([^'"]+)['"]/gm;
56
- while ((m = reExportRe.exec(code)) !== null) {
57
- if (!specifiers.includes(m[1])) specifiers.push(m[1]);
58
- }
59
- return specifiers;
60
- }
61
-
62
- /** Walk the import graph - topological sort (leaves first). */
63
- function walkImportGraph(entry) {
64
- const visited = new Set();
65
- const order = [];
66
-
67
- function visit(file) {
68
- const abs = path.resolve(file);
69
- if (visited.has(abs)) return;
70
- visited.add(abs);
71
-
72
- if (!fs.existsSync(abs)) {
73
- console.warn(` ⚠ Missing file: ${abs}`);
74
- return;
75
- }
76
-
77
- const code = fs.readFileSync(abs, 'utf-8');
78
- const imports = extractImports(code);
79
- for (const spec of imports) {
80
- const resolved = resolveImport(spec, abs);
81
- if (resolved) visit(resolved);
82
- }
83
- order.push(abs);
84
- }
85
-
86
- visit(entry);
87
- return order;
88
- }
89
-
90
- /**
91
- * Strip ES module import/export syntax, keeping declarations.
92
- * Exported const/let are converted to var so they hoist past the per-module
93
- * block scope and remain accessible to downstream modules.
94
- * Exported function/class are converted to var assignments for the same reason.
95
- * Non-exported declarations stay as-is and remain block-scoped (private).
96
- *
97
- * Template literals are temporarily hidden via a character-level scanner
98
- * (handles nested backtick expressions) so that code examples inside
99
- * backtick strings aren't accidentally rewritten.
100
- */
101
- function stripModuleSyntax(code) {
102
- // -- Hide template literals (supports nesting) -------------------------
103
- const templates = [];
104
-
105
- function scanTemplateLiteral(str, start) {
106
- let i = start + 1; // skip opening backtick
107
- while (i < str.length) {
108
- const ch = str[i];
109
- if (ch === '\\') { i += 2; continue; }
110
- if (ch === '`') { return i + 1; } // end of template
111
- if (ch === '$' && str[i + 1] === '{') {
112
- i += 2; // skip ${
113
- let depth = 1;
114
- while (i < str.length && depth > 0) {
115
- const c = str[i];
116
- if (c === '{') { depth++; i++; }
117
- else if (c === '}') { depth--; i++; }
118
- else if (c === '`') { i = scanTemplateLiteral(str, i); }
119
- else if (c === "'" || c === '"') { i = skipString(str, i); }
120
- else if (c === '/' && str[i + 1] === '/') { while (i < str.length && str[i] !== '\n') i++; }
121
- else if (c === '/' && str[i + 1] === '*') { i += 2; while (i < str.length - 1 && !(str[i] === '*' && str[i + 1] === '/')) i++; i += 2; }
122
- else { i++; }
123
- }
124
- continue;
125
- }
126
- i++;
127
- }
128
- return i;
129
- }
130
-
131
- function skipString(str, start) {
132
- const q = str[start];
133
- let i = start + 1;
134
- while (i < str.length) {
135
- if (str[i] === '\\') { i += 2; continue; }
136
- if (str[i] === q) { return i + 1; }
137
- i++;
138
- }
139
- return i;
140
- }
141
-
142
- let hidden = '';
143
- let pos = 0;
144
- while (pos < code.length) {
145
- const ch = code[pos];
146
- if (ch === "'" || ch === '"') {
147
- const end = skipString(code, pos);
148
- hidden += code.substring(pos, end);
149
- pos = end;
150
- } else if (ch === '/' && code[pos + 1] === '/') {
151
- let end = pos;
152
- while (end < code.length && code[end] !== '\n') end++;
153
- hidden += code.substring(pos, end);
154
- pos = end;
155
- } else if (ch === '/' && code[pos + 1] === '*') {
156
- let end = pos + 2;
157
- while (end < code.length - 1 && !(code[end] === '*' && code[end + 1] === '/')) end++;
158
- end += 2;
159
- hidden += code.substring(pos, end);
160
- pos = end;
161
- } else if (ch === '`') {
162
- const end = scanTemplateLiteral(code, pos);
163
- templates.push(code.substring(pos, end));
164
- hidden += `__TPL_${templates.length - 1}__`;
165
- pos = end;
166
- } else {
167
- hidden += ch;
168
- pos++;
169
- }
170
- }
171
-
172
- // -- Apply import / export transforms -----------------------------------
173
- code = hidden;
174
- code = code.replace(/^\s*import\s+[\s\S]*?from\s+['"].*?['"];?\s*$/gm, '');
175
- code = code.replace(/^\s*import\s+['"].*?['"];?\s*$/gm, '');
176
- code = code.replace(/^(\s*)export\s+default\s+/gm, '$1');
177
- // Convert exported const/let/var to var (hoists past block scope)
178
- code = code.replace(/^(\s*)export\s+(const|let|var)\s/gm, '$1var ');
179
- // Convert exported function/async function to var assignment (hoists past block scope)
180
- code = code.replace(/^(\s*)export\s+async\s+function\s+(\w+)/gm, '$1var $2 = async function $2');
181
- code = code.replace(/^(\s*)export\s+function\s+(\w+)/gm, '$1var $2 = function $2');
182
- // Convert exported class to var assignment
183
- code = code.replace(/^(\s*)export\s+class\s+(\w+)/gm, '$1var $2 = class $2');
184
- // Collect names from bare export blocks: export { a, b, c };
185
- // These names are declared elsewhere (function/const/let) and need to be
186
- // hoisted past the per-module block scope. We collect them here and
187
- // the caller converts their declarations to var-based forms.
188
- const bareExportNames = [];
189
- code = code.replace(/^\s*export\s*\{([^}]+)\};?\s*$/gm, (_, names) => {
190
- for (const n of names.split(',')) {
191
- const parts = n.trim().split(/\s+as\s+/);
192
- if (parts[0]) bareExportNames.push({ local: parts[0].trim(), exported: (parts[1] || parts[0]).trim() });
193
- }
194
- return '';
195
- });
196
-
197
- // For every bare-exported name, convert its block-scoped declaration to a
198
- // var-based form so it hoists past the per-module { } wrapper:
199
- // function foo(…) { … } → var foo = function foo(…) { … }
200
- // async function foo(…) → var foo = async function foo(…)
201
- // const/let foo = … → var foo = …
202
- for (const { local } of bareExportNames) {
203
- const fnRe = new RegExp(`^(\\s*)function\\s+${local}\\s*\\(`, 'gm');
204
- code = code.replace(fnRe, `$1var ${local} = function ${local}(`);
205
- const asyncFnRe = new RegExp(`^(\\s*)async\\s+function\\s+${local}\\s*\\(`, 'gm');
206
- code = code.replace(asyncFnRe, `$1var ${local} = async function ${local}(`);
207
- const constLetRe = new RegExp(`^(\\s*)(const|let)\\s+${local}\\b`, 'gm');
208
- code = code.replace(constLetRe, `$1var ${local}`);
209
- }
210
-
211
- // Create aliases for "export { local as exported }" where the names differ
212
- for (const { local, exported } of bareExportNames) {
213
- if (exported !== local) {
214
- code += `\nvar ${exported} = ${local};`;
215
- }
216
- }
217
-
218
- // -- Restore template literals ------------------------------------------
219
- code = code.replace(/__TPL_(\d+)__/g, (_, i) => templates[i]);
220
- return { code, bareExportNames };
221
- }
222
-
223
- /** Replace import.meta.url with a runtime equivalent. */
224
- function replaceImportMeta(code, filePath, projectRoot) {
225
- if (!code.includes('import.meta')) return code;
226
- const rel = path.relative(projectRoot, filePath).replace(/\\/g, '/');
227
- return code.replace(/import\.meta\.url/g, `(new URL('${rel}', document.baseURI).href)`);
228
- }
229
-
230
- /**
231
- * Rewrite templateUrl / styleUrl relative paths to project-relative paths.
232
- * In a bundled IIFE, caller-base detection returns undefined (all stack
233
- * frames point to the single bundle file), so relative URLs like
234
- * 'contacts.html' never resolve to their original directory. By rewriting
235
- * them to the same project-relative keys used in window.__zqInline, the
236
- * runtime inline-resource lookup succeeds without a network fetch.
237
- */
238
- function rewriteResourceUrls(code, filePath, projectRoot) {
239
- const fileDir = path.dirname(filePath);
240
- return code.replace(
241
- /((?:templateUrl|styleUrl)\s*:\s*)(['"])([^'"]+)\2/g,
242
- (match, prefix, quote, url) => {
243
- if (url.startsWith('/') || url.includes('://')) return match;
244
- const abs = path.resolve(fileDir, url);
245
- // Only rewrite if the file actually exists - avoids mangling code examples
246
- if (!fs.existsSync(abs)) return match;
247
- const rel = path.relative(projectRoot, abs).replace(/\\/g, '/');
248
- return `${prefix}${quote}${rel}${quote}`;
249
- }
250
- );
251
- }
252
-
253
- /**
254
- * Minify HTML for inlining - strips indentation and collapses whitespace
255
- * between tags. Preserves content inside <pre>, <code>, and <textarea>
256
- * blocks verbatim so syntax-highlighted code samples survive.
257
- */
258
- function minifyHTML(html) {
259
- const preserved = [];
260
- // Protect <pre>…</pre> and <textarea>…</textarea> (multiline code blocks)
261
- html = html.replace(/<(pre|textarea)(\b[^>]*)>[\s\S]*?<\/\1>/gi, m => {
262
- preserved.push(m);
263
- return `\x00P${preserved.length - 1}\x00`;
264
- });
265
- // Strip HTML comments
266
- html = html.replace(/<!--[\s\S]*?-->/g, '');
267
- // Collapse runs of whitespace (newlines + indentation) to a single space
268
- html = html.replace(/\s{2,}/g, ' ');
269
- // Remove space between tags: "> <" → "><"
270
- // but preserve a space when an inline element is involved (a, span, strong, em, b, i, code, small, sub, sup, abbr, label)
271
- html = html.replace(/>\s+</g, (m, offset) => {
272
- const before = html.slice(Math.max(0, offset - 80), offset + 1);
273
- const after = html.slice(offset + m.length - 1, offset + m.length + 40);
274
- const inlineTags = /\b(a|span|strong|em|b|i|code|small|sub|sup|abbr|label)\b/i;
275
- const closingInline = /<\/\s*(a|span|strong|em|b|i|code|small|sub|sup|abbr|label)\s*>$/i.test(before);
276
- const openingInline = /^<(a|span|strong|em|b|i|code|small|sub|sup|abbr|label)[\s>]/i.test(after);
277
- return (closingInline || openingInline) ? '> <' : '><';
278
- });
279
- // Remove spaces inside opening tags: <tag attr = "val" > → <tag attr="val">
280
- html = html.replace(/ *\/ *>/g, '/>');
281
- html = html.replace(/ *= */g, '=');
282
- // Trim
283
- html = html.trim();
284
- // Restore preserved blocks
285
- html = html.replace(/\x00P(\d+)\x00/g, (_, i) => preserved[+i]);
286
- return html;
287
- }
288
-
289
- /**
290
- * Minify CSS for inlining - strips comments, collapses whitespace,
291
- * removes unnecessary spaces around punctuation.
292
- */
293
- function minifyCSS(css) {
294
- // Strip block comments
295
- css = css.replace(/\/\*[\s\S]*?\*\//g, '');
296
- // Collapse whitespace
297
- css = css.replace(/\s{2,}/g, ' ');
298
- // Remove spaces around { } ; , (but NOT : — pseudo-selectors like :not() need the preceding space)
299
- css = css.replace(/\s*([{};,])\s*/g, '$1');
300
- // Remove space after : (safe in both selectors and declarations)
301
- css = css.replace(/:\s+/g, ':');
302
- // Remove trailing semicolons before }
303
- css = css.replace(/;}/g, '}');
304
- return css.trim();
305
- }
306
-
307
- /**
308
- * Walk JS source and minify the HTML/CSS inside template literals.
309
- * Handles ${…} interpolations (with nesting) and preserves <pre> blocks.
310
- * Only trims whitespace sequences that appear between/around HTML tags.
311
- */
312
- function minifyTemplateLiterals(code) {
313
- let out = '';
314
- let i = 0;
315
- while (i < code.length) {
316
- const ch = code[i];
317
-
318
- // Regular string: copy verbatim
319
- if (ch === '"' || ch === "'") {
320
- const q = ch; out += ch; i++;
321
- while (i < code.length) {
322
- if (code[i] === '\\') { out += code[i] + (code[i + 1] || ''); i += 2; continue; }
323
- out += code[i];
324
- if (code[i] === q) { i++; break; }
325
- i++;
326
- }
327
- continue;
328
- }
329
-
330
- // Line comment: copy verbatim
331
- if (ch === '/' && code[i + 1] === '/') {
332
- while (i < code.length && code[i] !== '\n') out += code[i++];
333
- continue;
334
- }
335
- // Block comment: copy verbatim
336
- if (ch === '/' && code[i + 1] === '*') {
337
- out += '/*'; i += 2;
338
- while (i < code.length && !(code[i] === '*' && code[i + 1] === '/')) out += code[i++];
339
- out += '*/'; i += 2;
340
- continue;
341
- }
342
-
343
- // Template literal: extract, minify HTML, and emit
344
- if (ch === '`') {
345
- out += _minifyTemplate(code, i);
346
- // Advance past the template
347
- i = _skipTemplateLiteral(code, i);
348
- continue;
349
- }
350
-
351
- out += ch; i++;
352
- }
353
- return out;
354
- }
355
-
356
- /** Extract a full template literal (handling nested ${…}) and return it minified. */
357
- function _minifyTemplate(code, start) {
358
- const end = _skipTemplateLiteral(code, start);
359
- const raw = code.substring(start, end);
360
- // Only minify templates that contain HTML tags or CSS rules
361
- if (/<\w/.test(raw)) return _collapseTemplateWS(raw);
362
- if (/[{};]\s/.test(raw) && /[.#\w-]+\s*\{/.test(raw)) return _collapseTemplateCSS(raw);
363
- return raw;
364
- }
365
-
366
- /** Return index past the closing backtick of a template starting at `start`. */
367
- function _skipTemplateLiteral(code, start) {
368
- let i = start + 1; // skip opening backtick
369
- let depth = 0;
370
- while (i < code.length) {
371
- if (code[i] === '\\') { i += 2; continue; }
372
- if (code[i] === '$' && code[i + 1] === '{') { depth++; i += 2; continue; }
373
- if (depth > 0) {
374
- if (code[i] === '{') { depth++; i++; continue; }
375
- if (code[i] === '}') { depth--; i++; continue; }
376
- if (code[i] === '`') { i = _skipTemplateLiteral(code, i); continue; }
377
- if (code[i] === '"' || code[i] === "'") {
378
- const q = code[i]; i++;
379
- while (i < code.length) {
380
- if (code[i] === '\\') { i += 2; continue; }
381
- if (code[i] === q) { i++; break; }
382
- i++;
383
- }
384
- continue;
385
- }
386
- i++; continue;
387
- }
388
- if (code[i] === '`') { i++; return i; } // closing backtick
389
- i++;
390
- }
391
- return i;
392
- }
393
-
394
- /**
395
- * Collapse whitespace in the text portions of an HTML template literal,
396
- * preserving ${…} expressions, <pre> blocks, and inline text spacing.
397
- */
398
- function _collapseTemplateWS(tpl) {
399
- // Build array of segments: text portions vs ${…} expressions
400
- const segments = [];
401
- let i = 1; // skip opening backtick
402
- let text = '';
403
- while (i < tpl.length - 1) { // stop before closing backtick
404
- if (tpl[i] === '\\') { text += tpl[i] + (tpl[i + 1] || ''); i += 2; continue; }
405
- if (tpl[i] === '$' && tpl[i + 1] === '{') {
406
- if (text) { segments.push({ type: 'text', val: text }); text = ''; }
407
- // Collect the full expression
408
- let depth = 1; let expr = '${'; i += 2;
409
- while (i < tpl.length - 1 && depth > 0) {
410
- if (tpl[i] === '\\') { expr += tpl[i] + (tpl[i + 1] || ''); i += 2; continue; }
411
- if (tpl[i] === '{') depth++;
412
- if (tpl[i] === '}') depth--;
413
- if (depth > 0) { expr += tpl[i]; i++; } else { expr += '}'; i++; }
414
- }
415
- segments.push({ type: 'expr', val: expr });
416
- continue;
417
- }
418
- text += tpl[i]; i++;
419
- }
420
- if (text) segments.push({ type: 'text', val: text });
421
-
422
- // Minify text segments (collapse whitespace between/around tags)
423
- for (let s = 0; s < segments.length; s++) {
424
- if (segments[s].type !== 'text') continue;
425
- let t = segments[s].val;
426
- // Protect <pre>…</pre> regions
427
- const preserved = [];
428
- t = t.replace(/<pre(\b[^>]*)>[\s\S]*?<\/pre>/gi, m => {
429
- preserved.push(m);
430
- return `\x00P${preserved.length - 1}\x00`;
431
- });
432
- // Collapse whitespace runs that touch a < or > but preserve a space
433
- // when an inline element boundary is involved
434
- t = t.replace(/>\s{2,}/g, (m, offset) => {
435
- const before = t.slice(Math.max(0, offset - 80), offset + 1);
436
- if (/<\/\s*(a|span|strong|em|b|i|code|small|sub|sup|abbr|label)\s*>$/i.test(before)) return '> ';
437
- return '>';
438
- });
439
- t = t.replace(/\s{2,}</g, (m, offset) => {
440
- const after = t.slice(offset + m.length - 1, offset + m.length + 40);
441
- if (/^<(a|span|strong|em|b|i|code|small|sub|sup|abbr|label)[\s>]/i.test(after)) return ' <';
442
- return '<';
443
- });
444
- // Collapse other multi-whitespace runs to a single space
445
- t = t.replace(/\s{2,}/g, ' ');
446
- // Restore <pre> blocks
447
- t = t.replace(/\x00P(\d+)\x00/g, (_, idx) => preserved[+idx]);
448
- segments[s].val = t;
449
- }
450
-
451
- return '`' + segments.map(s => s.val).join('') + '`';
452
- }
453
-
454
- /**
455
- * Collapse CSS whitespace inside a template literal (styles: `…`).
456
- * Uses the same segment-splitting approach as _collapseTemplateWS so
457
- * ${…} expressions are preserved untouched.
458
- */
459
- function _collapseTemplateCSS(tpl) {
460
- const segments = [];
461
- let i = 1; let text = '';
462
- while (i < tpl.length - 1) {
463
- if (tpl[i] === '\\') { text += tpl[i] + (tpl[i + 1] || ''); i += 2; continue; }
464
- if (tpl[i] === '$' && tpl[i + 1] === '{') {
465
- if (text) { segments.push({ type: 'text', val: text }); text = ''; }
466
- let depth = 1; let expr = '${'; i += 2;
467
- while (i < tpl.length - 1 && depth > 0) {
468
- if (tpl[i] === '\\') { expr += tpl[i] + (tpl[i + 1] || ''); i += 2; continue; }
469
- if (tpl[i] === '{') depth++;
470
- if (tpl[i] === '}') depth--;
471
- if (depth > 0) { expr += tpl[i]; i++; } else { expr += '}'; i++; }
472
- }
473
- segments.push({ type: 'expr', val: expr });
474
- continue;
475
- }
476
- text += tpl[i]; i++;
477
- }
478
- if (text) segments.push({ type: 'text', val: text });
479
-
480
- for (let s = 0; s < segments.length; s++) {
481
- if (segments[s].type !== 'text') continue;
482
- let t = segments[s].val;
483
- t = t.replace(/\/\*[\s\S]*?\*\//g, '');
484
- t = t.replace(/\s{2,}/g, ' ');
485
- t = t.replace(/\s*([{};,])\s*/g, '$1');
486
- t = t.replace(/:\s+/g, ':');
487
- t = t.replace(/;}/g, '}');
488
- segments[s].val = t;
489
- }
490
- return '`' + segments.map(s => s.val).join('') + '`';
491
- }
492
-
493
- /**
494
- * Scan bundled source files for external resource references
495
- * (templateUrl, styleUrl) and return a map of
496
- * { relativePath: fileContent } for inlining.
497
- */
498
- function collectInlineResources(files, projectRoot) {
499
- const inlineMap = {};
500
-
501
- for (const file of files) {
502
- const code = fs.readFileSync(file, 'utf-8');
503
- const fileDir = path.dirname(file);
504
-
505
- // styleUrl:
506
- const styleUrlRe = /styleUrl\s*:\s*['"]([^'"]+)['"]/g;
507
- let styleMatch;
508
- while ((styleMatch = styleUrlRe.exec(code)) !== null) {
509
- const stylePath = path.join(fileDir, styleMatch[1]);
510
- if (fs.existsSync(stylePath)) {
511
- const relKey = path.relative(projectRoot, stylePath).replace(/\\/g, '/');
512
- inlineMap[relKey] = fs.readFileSync(stylePath, 'utf-8');
513
- }
514
- }
515
-
516
- // templateUrl:
517
- const tmplMatch = code.match(/templateUrl\s*:\s*['"]([^'"]+)['"]/);
518
- if (tmplMatch) {
519
- const tmplPath = path.join(fileDir, tmplMatch[1]);
520
- if (fs.existsSync(tmplPath)) {
521
- const relKey = path.relative(projectRoot, tmplPath).replace(/\\/g, '/');
522
- inlineMap[relKey] = fs.readFileSync(tmplPath, 'utf-8');
523
- }
524
- } else if (/templateUrl\s*:/.test(code)) {
525
- // Dynamic templateUrl (e.g. Object.fromEntries, computed map) -
526
- // inline all .html files in the component's directory tree so
527
- // the runtime __zqInline lookup can resolve them by suffix.
528
- (function scanHtml(dir) {
529
- try {
530
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
531
- const full = path.join(dir, entry.name);
532
- if (entry.isFile() && entry.name.endsWith('.html')) {
533
- const relKey = path.relative(projectRoot, full).replace(/\\/g, '/');
534
- if (!inlineMap[relKey]) {
535
- inlineMap[relKey] = fs.readFileSync(full, 'utf-8');
536
- }
537
- } else if (entry.isDirectory()) {
538
- scanHtml(full);
539
- }
540
- }
541
- } catch { /* permission error - skip */ }
542
- })(fileDir);
543
- }
544
- }
545
-
546
- // Minify inlined resources by type
547
- for (const key of Object.keys(inlineMap)) {
548
- if (key.endsWith('.html')) inlineMap[key] = minifyHTML(inlineMap[key]);
549
- else if (key.endsWith('.css')) inlineMap[key] = minifyCSS(inlineMap[key]);
550
- }
551
-
552
- return inlineMap;
553
- }
554
-
555
- /**
556
- * Auto-detect the app entry point.
557
- *
558
- * Strategy - ordered by precedence (first match wins):
559
- * 1. HTML discovery: index.html first, then other .html files
560
- * (root level + one directory deep).
561
- * 2. Within each HTML file, prefer a module <script> whose src
562
- * resolves to app.js, then fall back to the first module
563
- * <script> tag regardless of name.
564
- * 3. JS file scan: look for $.router first (entry point by design),
565
- * then $.mount / $.store / mountAll.
566
- * 4. Convention fallback paths.
567
- */
568
- function detectEntry(projectRoot) {
569
- // Matches all <script type="module" src="…"> tags (global)
570
- const moduleScriptReG = /<script[^>]+type\s*=\s*["']module["'][^>]+src\s*=\s*["']([^"']+)["']/g;
571
-
572
- // Collect HTML files: root level + one directory deep
573
- const htmlFiles = [];
574
- for (const entry of fs.readdirSync(projectRoot, { withFileTypes: true })) {
575
- if (entry.isFile() && entry.name.endsWith('.html')) {
576
- htmlFiles.push(path.join(projectRoot, entry.name));
577
- } else if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules' && entry.name !== 'dist') {
578
- const sub = path.join(projectRoot, entry.name);
579
- try {
580
- for (const child of fs.readdirSync(sub, { withFileTypes: true })) {
581
- if (child.isFile() && child.name.endsWith('.html')) {
582
- htmlFiles.push(path.join(sub, child.name));
583
- }
584
- }
585
- } catch { /* permission error - skip */ }
586
- }
587
- }
588
-
589
- // Sort: index.html first (at any depth), then alphabetical
590
- htmlFiles.sort((a, b) => {
591
- const aIsIndex = path.basename(a) === 'index.html' ? 0 : 1;
592
- const bIsIndex = path.basename(b) === 'index.html' ? 0 : 1;
593
- if (aIsIndex !== bIsIndex) return aIsIndex - bIsIndex;
594
- return a.localeCompare(b);
595
- });
596
-
597
- // 1. Parse module <script> tags from HTML files (index.html evaluated first).
598
- // Within each file prefer a src ending in app.js, else the first module tag.
599
- for (const htmlPath of htmlFiles) {
600
- const html = fs.readFileSync(htmlPath, 'utf-8');
601
- const htmlDir = path.dirname(htmlPath);
602
-
603
- let appJsEntry = null; // src that resolves to app.js
604
- let firstEntry = null; // first module script src
605
- let m;
606
- moduleScriptReG.lastIndex = 0;
607
- while ((m = moduleScriptReG.exec(html)) !== null) {
608
- const resolved = path.resolve(htmlDir, m[1]);
609
- if (!fs.existsSync(resolved)) continue;
610
- if (!firstEntry) firstEntry = resolved;
611
- if (path.basename(resolved) === 'app.js') { appJsEntry = resolved; break; }
612
- }
613
-
614
- if (appJsEntry) return appJsEntry;
615
- if (firstEntry) return firstEntry;
616
- }
617
-
618
- // 2. Search JS files for entry-point patterns.
619
- // Pass 1 - $.router (the canonical entry point).
620
- // Pass 2 - $.mount, $.store, mountAll (component-level, lower confidence).
621
- const routerRe = /\$\.router\s*\(/;
622
- const otherRe = /\$\.(mount|store)\s*\(|mountAll\s*\(/;
623
-
624
- function collectJS(dir, depth = 0) {
625
- const results = [];
626
- if (depth > 2) return results;
627
- try {
628
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
629
- if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'dist') continue;
630
- const full = path.join(dir, entry.name);
631
- if (entry.isFile() && entry.name.endsWith('.js')) {
632
- results.push(full);
633
- } else if (entry.isDirectory()) {
634
- results.push(...collectJS(full, depth + 1));
635
- }
636
- }
637
- } catch { /* skip */ }
638
- return results;
639
- }
640
-
641
- const jsFiles = collectJS(projectRoot);
642
-
643
- // Pass 1: $.router
644
- for (const file of jsFiles) {
645
- const code = fs.readFileSync(file, 'utf-8');
646
- if (routerRe.test(code)) return file;
647
- }
648
- // Pass 2: other entry-point signals
649
- for (const file of jsFiles) {
650
- const code = fs.readFileSync(file, 'utf-8');
651
- if (otherRe.test(code)) return file;
652
- }
653
-
654
- // 3. Convention fallbacks
655
- const fallbacks = ['app/app.js', 'scripts/app.js', 'src/app.js', 'js/app.js', 'app.js', 'main.js'];
656
- for (const f of fallbacks) {
657
- const fp = path.join(projectRoot, f);
658
- if (fs.existsSync(fp)) return fp;
659
- }
660
- return null;
661
- }
662
-
663
- // ---------------------------------------------------------------------------
664
- // HTML rewriting
665
- // ---------------------------------------------------------------------------
666
-
667
- /**
668
- * Rewrite an HTML file to replace the module <script> with the bundle.
669
- * Produces two variants:
670
- * server/index.html - <base href="/"> for SPA deep routes
671
- * local/index.html - relative paths for file:// access
672
- */
673
- function rewriteHtml(projectRoot, htmlRelPath, bundleFile, includeLib, bundledFiles, serverDir, localDir, globalCssOrigHref, globalCssHash) {
674
- const htmlPath = path.resolve(projectRoot, htmlRelPath);
675
- if (!fs.existsSync(htmlPath)) {
676
- console.warn(` ⚠ HTML file not found: ${htmlRelPath}`);
677
- return;
678
- }
679
-
680
- const htmlDir = path.dirname(htmlPath);
681
- let html = fs.readFileSync(htmlPath, 'utf-8');
682
-
683
- // Collect asset references from HTML
684
- const assetRe = /(?:<(?:link|script|img)[^>]*?\s(?:src|href)\s*=\s*["'])([^"']+)["']/gi;
685
- const assets = new Set();
686
- let m;
687
- while ((m = assetRe.exec(html)) !== null) {
688
- const ref = m[1];
689
- if (ref.startsWith('http') || ref.startsWith('//') || ref.startsWith('data:') || ref.startsWith('#')) continue;
690
- const refAbs = path.resolve(htmlDir, ref);
691
- if (bundledFiles && bundledFiles.has(refAbs)) continue;
692
- if (includeLib && /zquery(?:\.min)?\.js$/i.test(ref)) continue;
693
- assets.add(ref);
694
- }
695
-
696
- // Also scan the bundled JS for src/href references to local assets
697
- const bundleContent = fs.existsSync(bundleFile) ? fs.readFileSync(bundleFile, 'utf-8') : '';
698
- const jsAssetRe = /\b(?:src|href)\s*=\s*["\\]*["']([^"']+\.(?:svg|png|jpg|jpeg|gif|webp|ico|woff2?|ttf|eot|mp4|webm))["']/gi;
699
- while ((m = jsAssetRe.exec(bundleContent)) !== null) {
700
- const ref = m[1];
701
- if (ref.startsWith('http') || ref.startsWith('//') || ref.startsWith('data:')) continue;
702
- if (!assets.has(ref)) {
703
- const refAbs = path.resolve(htmlDir, ref);
704
- if (fs.existsSync(refAbs)) assets.add(ref);
705
- }
706
- }
707
-
708
- // For any referenced asset directories, copy all sibling files too
709
- const assetDirs = new Set();
710
- for (const asset of assets) {
711
- const dir = path.dirname(asset);
712
- if (dir && dir !== '.') assetDirs.add(dir);
713
- }
714
- for (const dir of assetDirs) {
715
- const absDirPath = path.resolve(htmlDir, dir);
716
- if (fs.existsSync(absDirPath) && fs.statSync(absDirPath).isDirectory()) {
717
- for (const child of fs.readdirSync(absDirPath)) {
718
- const childRel = path.join(dir, child).replace(/\\/g, '/');
719
- const childAbs = path.join(absDirPath, child);
720
- if (fs.statSync(childAbs).isFile() && !assets.has(childRel)) {
721
- assets.add(childRel);
722
- }
723
- }
724
- }
725
- }
726
-
727
- // Copy assets into both dist dirs
728
- let copiedCount = 0;
729
- for (const asset of assets) {
730
- const srcFile = path.resolve(htmlDir, asset);
731
- if (!fs.existsSync(srcFile)) continue;
732
- for (const distDir of [serverDir, localDir]) {
733
- const destFile = path.join(distDir, asset);
734
- const destDir = path.dirname(destFile);
735
- if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
736
- fs.copyFileSync(srcFile, destFile);
737
- }
738
- copiedCount++;
739
- }
740
-
741
- // Copy CSS-referenced assets (fonts, images in url())
742
- for (const asset of assets) {
743
- const srcFile = path.resolve(htmlDir, asset);
744
- if (!fs.existsSync(srcFile) || !asset.endsWith('.css')) continue;
745
- const cssContent = fs.readFileSync(srcFile, 'utf-8');
746
- const urlRe = /url\(\s*["']?([^"')]+?)["']?\s*\)/g;
747
- let cm;
748
- while ((cm = urlRe.exec(cssContent)) !== null) {
749
- const ref = cm[1];
750
- if (ref.startsWith('data:') || ref.startsWith('http') || ref.startsWith('//')) continue;
751
- const cssSrcDir = path.dirname(srcFile);
752
- const assetSrc = path.resolve(cssSrcDir, ref);
753
- if (!fs.existsSync(assetSrc)) continue;
754
- for (const distDir of [serverDir, localDir]) {
755
- const cssDestDir = path.dirname(path.join(distDir, asset));
756
- const assetDest = path.resolve(cssDestDir, ref);
757
- if (!fs.existsSync(assetDest)) {
758
- const dir = path.dirname(assetDest);
759
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
760
- fs.copyFileSync(assetSrc, assetDest);
761
- }
762
- }
763
- copiedCount++;
764
- }
765
- }
766
-
767
- const bundleRel = path.relative(serverDir, bundleFile).replace(/\\/g, '/');
768
-
769
- html = html.replace(
770
- /<script\s+type\s*=\s*["']module["']\s+src\s*=\s*["'][^"']+["']\s*>\s*<\/script>/gi,
771
- `<script defer src="${bundleRel}"></script>`
772
- );
773
-
774
- if (includeLib) {
775
- html = html.replace(
776
- /\s*<script\s+src\s*=\s*["'][^"']*zquery(?:\.min)?\.js["']\s*>\s*<\/script>/gi,
777
- ''
778
- );
779
- }
780
-
781
- // Rewrite global CSS link to hashed version
782
- if (globalCssOrigHref && globalCssHash) {
783
- const escapedHref = globalCssOrigHref.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
784
- const cssLinkRe = new RegExp(
785
- `(<link[^>]+href\\s*=\\s*["'])${escapedHref}(["'][^>]*>)`, 'i'
786
- );
787
- html = html.replace(cssLinkRe, `$1${globalCssHash}$2`);
788
- }
789
-
790
- const serverHtml = html;
791
- const localHtml = html.replace(/<base\s+href\s*=\s*["']\/["'][^>]*>\s*\n?\s*/i, '');
792
-
793
- const htmlName = path.basename(htmlRelPath);
794
- fs.writeFileSync(path.join(serverDir, htmlName), serverHtml, 'utf-8');
795
- fs.writeFileSync(path.join(localDir, htmlName), localHtml, 'utf-8');
796
- console.log(` ✓ server/${htmlName} (with <base href="/">)`);
797
- console.log(` ✓ local/${htmlName} (relative paths, file:// ready)`);
798
- console.log(` ✓ Copied ${copiedCount} asset(s) into both dist dirs`);
799
- }
800
-
801
- // ---------------------------------------------------------------------------
802
- // Static asset copying
803
- // ---------------------------------------------------------------------------
804
-
805
- /**
806
- * Copy the entire app directory into both dist/server and dist/local,
807
- * skipping only build outputs, tooling dirs, and the app/ source dir.
808
- * This ensures all static assets (assets/, icons/, images/, fonts/,
809
- * manifests, etc.) are available in the built output without maintaining
810
- * a fragile whitelist.
811
- */
812
- function copyStaticAssets(appRoot, serverDir, localDir, bundledFiles) {
813
- const SKIP_DIRS = new Set(['dist', 'node_modules', '.git', '.vscode']);
814
-
815
- // Case-insensitive match for App/ directory (contains bundled source)
816
- function isAppDir(name) {
817
- return name.toLowerCase() === 'app';
818
- }
819
-
820
- let copiedCount = 0;
821
-
822
- function copyEntry(srcPath, relPath) {
823
- const stat = fs.statSync(srcPath);
824
- if (stat.isDirectory()) {
825
- const dirName = path.basename(srcPath);
826
- if (SKIP_DIRS.has(dirName) || isAppDir(dirName)) return;
827
- for (const child of fs.readdirSync(srcPath)) {
828
- copyEntry(path.join(srcPath, child), path.join(relPath, child));
829
- }
830
- } else {
831
- if (bundledFiles && bundledFiles.has(path.resolve(srcPath))) return;
832
- for (const distDir of [serverDir, localDir]) {
833
- const dest = path.join(distDir, relPath);
834
- if (fs.existsSync(dest)) continue; // already copied by rewriteHtml
835
- const dir = path.dirname(dest);
836
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
837
- fs.copyFileSync(srcPath, dest);
838
- }
839
- copiedCount++;
840
- }
841
- }
842
-
843
- for (const entry of fs.readdirSync(appRoot, { withFileTypes: true })) {
844
- if (entry.isDirectory()) {
845
- if (SKIP_DIRS.has(entry.name) || isAppDir(entry.name)) continue;
846
- copyEntry(path.join(appRoot, entry.name), entry.name);
847
- } else {
848
- copyEntry(path.join(appRoot, entry.name), entry.name);
849
- }
850
- }
851
-
852
- if (copiedCount > 0) {
853
- console.log(` \u2713 Copied ${copiedCount} additional static asset(s) into both dist dirs`);
854
- }
855
- }
856
-
857
- // ---------------------------------------------------------------------------
858
- // Main bundleApp function
859
- // ---------------------------------------------------------------------------
860
-
861
- function bundleApp() {
862
- const projectRoot = process.cwd();
863
- const minimal = flag('minimal', 'm');
864
- const globalCssOverride = option('global-css', null, null);
865
-
866
- // Entry point - positional arg (directory or file) or auto-detection
867
- let entry = null;
868
- let targetDir = null;
869
- for (let i = 1; i < args.length; i++) {
870
- if (!args[i].startsWith('-') && args[i - 1] !== '-o' && args[i - 1] !== '--out' && args[i - 1] !== '--index') {
871
- const resolved = path.resolve(projectRoot, args[i]);
872
- if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
873
- targetDir = resolved;
874
- entry = detectEntry(resolved);
875
- } else {
876
- entry = resolved;
877
- }
878
- break;
879
- }
880
- }
881
- if (!entry) entry = detectEntry(projectRoot);
882
-
883
- if (!entry || !fs.existsSync(entry)) {
884
- console.error(`\n \u2717 Could not find entry file.`);
885
- console.error(` Provide an app directory: zquery bundle my-app/`);
886
- console.error(` Or pass a direct entry file: zquery bundle my-app/scripts/main.js\n`);
887
- process.exit(1);
888
- }
889
-
890
- const outPath = option('out', 'o', null);
891
-
892
- // Auto-detect HTML file
893
- let htmlFile = option('index', 'i', null);
894
- let htmlAbs = htmlFile ? path.resolve(projectRoot, htmlFile) : null;
895
- if (!htmlFile) {
896
- // Strategy: first look for index.html walking up from entry, then
897
- // scan for any .html that references the entry via a module script tag.
898
- const htmlCandidates = [];
899
- let entryDir = path.dirname(entry);
900
- while (entryDir.length >= projectRoot.length) {
901
- htmlCandidates.push(path.join(entryDir, 'index.html'));
902
- const parent = path.dirname(entryDir);
903
- if (parent === entryDir) break;
904
- entryDir = parent;
905
- }
906
- htmlCandidates.push(path.join(projectRoot, 'index.html'));
907
- htmlCandidates.push(path.join(projectRoot, 'public/index.html'));
908
- for (const candidate of htmlCandidates) {
909
- if (fs.existsSync(candidate)) {
910
- htmlAbs = candidate;
911
- htmlFile = path.relative(projectRoot, candidate);
912
- break;
913
- }
914
- }
915
-
916
- // If no index.html found, scan for any .html file that references
917
- // the entry point (supports home.html, app.html, etc.)
918
- if (!htmlAbs) {
919
- const searchRoot = targetDir || projectRoot;
920
- const htmlScan = [];
921
- for (const e of fs.readdirSync(searchRoot, { withFileTypes: true })) {
922
- if (e.isFile() && e.name.endsWith('.html')) {
923
- htmlScan.push(path.join(searchRoot, e.name));
924
- } else if (e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules' && e.name !== 'dist') {
925
- try {
926
- for (const child of fs.readdirSync(path.join(searchRoot, e.name), { withFileTypes: true })) {
927
- if (child.isFile() && child.name.endsWith('.html')) {
928
- htmlScan.push(path.join(searchRoot, e.name, child.name));
929
- }
930
- }
931
- } catch { /* skip */ }
932
- }
933
- }
934
- // Prefer the HTML file that references our entry via a module script
935
- const moduleScriptRe = /<script[^>]+type\s*=\s*["']module["'][^>]+src\s*=\s*["']([^"']+)["']/g;
936
- for (const hp of htmlScan) {
937
- const content = fs.readFileSync(hp, 'utf-8');
938
- let m;
939
- moduleScriptRe.lastIndex = 0;
940
- while ((m = moduleScriptRe.exec(content)) !== null) {
941
- const resolved = path.resolve(path.dirname(hp), m[1]);
942
- if (resolved === path.resolve(entry)) {
943
- htmlAbs = hp;
944
- htmlFile = path.relative(projectRoot, hp);
945
- break;
946
- }
947
- }
948
- if (htmlAbs) break;
949
- }
950
- // Last resort: use the first .html found
951
- if (!htmlAbs && htmlScan.length > 0) {
952
- htmlAbs = htmlScan[0];
953
- htmlFile = path.relative(projectRoot, htmlScan[0]);
954
- }
955
- }
956
- }
957
-
958
- // Output directory
959
- const entryRel = path.relative(projectRoot, entry);
960
- const entryName = path.basename(entry, '.js');
961
- let baseDistDir;
962
- if (outPath) {
963
- const resolved = path.resolve(projectRoot, outPath);
964
- if (outPath.endsWith('/') || outPath.endsWith('\\') || !path.extname(outPath) ||
965
- (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory())) {
966
- baseDistDir = resolved;
967
- } else {
968
- baseDistDir = path.dirname(resolved);
969
- }
970
- } else if (htmlAbs) {
971
- baseDistDir = path.join(path.dirname(htmlAbs), 'dist');
972
- } else {
973
- baseDistDir = path.join(projectRoot, 'dist');
974
- }
975
-
976
- const serverDir = path.join(baseDistDir, 'server');
977
- const localDir = path.join(baseDistDir, 'local');
978
-
979
- console.log(`\n zQuery App Bundler`);
980
- console.log(` Entry: ${entryRel}`);
981
- console.log(` Output: ${path.relative(projectRoot, baseDistDir)}/server/ & local/`);
982
- console.log(` Library: embedded`);
983
- console.log(` HTML: ${htmlFile || 'not found (no HTML detected)'}`);
984
- if (minimal) console.log(` Mode: minimal (HTML + JS + global CSS only)`);
985
- console.log('');
986
-
987
- // ------ doBuild (inlined) ------
988
- const start = Date.now();
989
-
990
- // Clean previous dist outputs for a fresh build
991
- for (const dir of [serverDir, localDir]) {
992
- if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true });
993
- }
994
- fs.mkdirSync(serverDir, { recursive: true });
995
- fs.mkdirSync(localDir, { recursive: true });
996
-
997
- const files = walkImportGraph(entry);
998
- console.log(` Resolved ${files.length} module(s):`);
999
- files.forEach(f => console.log(` • ${path.relative(projectRoot, f)}`));
1000
-
1001
- const sections = files.map(file => {
1002
- let code = fs.readFileSync(file, 'utf-8');
1003
- const stripped = stripModuleSyntax(code);
1004
- code = stripped.code;
1005
- code = replaceImportMeta(code, file, projectRoot);
1006
- code = rewriteResourceUrls(code, file, projectRoot);
1007
- code = minifyTemplateLiterals(code);
1008
- const rel = path.relative(projectRoot, file);
1009
- return `// --- ${rel} ${'-'.repeat(Math.max(1, 60 - rel.length))}\n{\n${code.trim()}\n}`;
1010
- });
1011
-
1012
- // Embed zquery.min.js
1013
- let libSection = '';
1014
- {
1015
- // __dirname is cli/commands/, so the package root is two levels up
1016
- const pkgRoot = path.resolve(__dirname, '..', '..');
1017
- const pkgSrcDir = path.join(pkgRoot, 'src');
1018
- const pkgMinFile = path.join(pkgRoot, 'dist', 'zquery.min.js');
1019
-
1020
- if (fs.existsSync(pkgSrcDir) && fs.existsSync(path.join(pkgRoot, 'index.js'))) {
1021
- console.log(`\n Building library from source...`);
1022
- const prevCwd = process.cwd();
1023
- try {
1024
- process.chdir(pkgRoot);
1025
- buildLibrary();
1026
- } finally {
1027
- process.chdir(prevCwd);
1028
- }
1029
- }
1030
-
1031
- const htmlDir = htmlAbs ? path.dirname(htmlAbs) : null;
1032
-
1033
- // Case-insensitive search for Assets/ directory
1034
- function findAssetsDir(root) {
1035
- try {
1036
- for (const e of fs.readdirSync(root, { withFileTypes: true })) {
1037
- if (e.isDirectory() && e.name.toLowerCase() === 'assets') return path.join(root, e.name);
1038
- }
1039
- } catch { /* skip */ }
1040
- return null;
1041
- }
1042
-
1043
- const assetsDir = findAssetsDir(htmlDir || projectRoot);
1044
- const altAssetsDir = htmlDir ? findAssetsDir(projectRoot) : null;
1045
-
1046
- const libCandidates = [
1047
- path.join(pkgRoot, 'dist/zquery.min.js'),
1048
- assetsDir && path.join(assetsDir, 'scripts/zquery.min.js'),
1049
- altAssetsDir && path.join(altAssetsDir, 'scripts/zquery.min.js'),
1050
- htmlDir && path.join(htmlDir, 'scripts/vendor/zquery.min.js'),
1051
- htmlDir && path.join(htmlDir, 'vendor/zquery.min.js'),
1052
- path.join(projectRoot, 'scripts/vendor/zquery.min.js'),
1053
- path.join(projectRoot, 'vendor/zquery.min.js'),
1054
- path.join(projectRoot, 'lib/zquery.min.js'),
1055
- path.join(projectRoot, 'dist/zquery.min.js'),
1056
- path.join(projectRoot, 'zquery.min.js'),
1057
- ].filter(Boolean);
1058
- const libPath = libCandidates.find(p => fs.existsSync(p));
1059
-
1060
- if (libPath) {
1061
- const libBytes = fs.statSync(libPath).size;
1062
- libSection = `// --- zquery.min.js (library) ${'-'.repeat(34)}\n${fs.readFileSync(libPath, 'utf-8').trim()}\n\n`
1063
- + `// --- Build-time metadata ${'-'.repeat(50)}\nif(typeof $!=="undefined"){$.meta=Object.assign($.meta||{},{libSize:${libBytes}});}\n\n`;
1064
- console.log(` Embedded library from ${path.relative(projectRoot, libPath)} (${(libBytes / 1024).toFixed(1)} KB)`);
1065
- } else {
1066
- console.warn(`\n ⚠ Could not find zquery.min.js anywhere`);
1067
- console.warn(` Place zquery.min.js in assets/scripts/, vendor/, lib/, or dist/`);
1068
- }
1069
- }
1070
-
1071
- const banner = `/**\n * App bundle - built by zQuery CLI\n * Entry: ${entryRel}\n * ${new Date().toISOString()}\n */`;
1072
-
1073
- // Inline resources
1074
- const inlineMap = collectInlineResources(files, projectRoot);
1075
- let inlineSection = '';
1076
- if (Object.keys(inlineMap).length > 0) {
1077
- const entries = Object.entries(inlineMap).map(([key, content]) => {
1078
- const escaped = content
1079
- .replace(/\\/g, '\\\\')
1080
- .replace(/'/g, "\\'")
1081
- .replace(/\n/g, '\\n')
1082
- .replace(/\r/g, '');
1083
- return ` '${key}': '${escaped}'`;
1084
- });
1085
- inlineSection = `// --- Inlined resources (file:// support) ${'-'.repeat(20)}\nwindow.__zqInline = {\n${entries.join(',\n')}\n};\n\n`;
1086
- console.log(`\n Inlined ${Object.keys(inlineMap).length} external resource(s)`);
1087
- }
1088
-
1089
- const bundle = `${banner}\n(function() {\n 'use strict';\n\n${libSection}${inlineSection}${sections.join('\n\n')}\n\n})();\n`;
1090
-
1091
- // Content-hashed filenames
1092
- const contentHash = crypto.createHash('sha256').update(bundle).digest('hex').slice(0, 8);
1093
- const minBase = `z-${entryName}.${contentHash}.min.js`;
1094
-
1095
- // Clean previous builds
1096
- const escName = entryName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1097
- const cleanRe = new RegExp(`^z-${escName}\\.[a-f0-9]{8}\\.(?:min\\.)?js$`);
1098
- for (const dir of [serverDir, localDir]) {
1099
- if (fs.existsSync(dir)) {
1100
- for (const f of fs.readdirSync(dir)) {
1101
- if (cleanRe.test(f)) fs.unlinkSync(path.join(dir, f));
1102
- }
1103
- }
1104
- }
1105
-
1106
- // Write minified bundle
1107
- const minFile = path.join(serverDir, minBase);
1108
- fs.writeFileSync(minFile, minify(bundle, banner), 'utf-8');
1109
- fs.copyFileSync(minFile, path.join(localDir, minBase));
1110
-
1111
- console.log(`\n ✓ ${minBase} (${sizeKB(fs.readFileSync(minFile))} KB)`);
1112
-
1113
- // ------------------------------------------------------------------
1114
- // Global CSS bundling - extract from index.html <link> or --global-css
1115
- // ------------------------------------------------------------------
1116
- let globalCssHash = null;
1117
- let globalCssOrigHref = null;
1118
- let globalCssPath = null;
1119
- if (htmlAbs) {
1120
- const htmlContent = fs.readFileSync(htmlAbs, 'utf-8');
1121
- const htmlDir = path.dirname(htmlAbs);
1122
-
1123
- // Determine global CSS path: --global-css flag overrides, else first <link rel="stylesheet"> in HTML
1124
- globalCssPath = null;
1125
- if (globalCssOverride) {
1126
- globalCssPath = path.resolve(projectRoot, globalCssOverride);
1127
- // Reconstruct relative href for HTML rewriting
1128
- globalCssOrigHref = path.relative(htmlDir, globalCssPath).replace(/\\/g, '/');
1129
- } else {
1130
- const linkRe = /<link[^>]+rel\s*=\s*["']stylesheet["'][^>]+href\s*=\s*["']([^"']+)["']/gi;
1131
- const altRe = /<link[^>]+href\s*=\s*["']([^"']+)["'][^>]+rel\s*=\s*["']stylesheet["']/gi;
1132
- let linkMatch = linkRe.exec(htmlContent) || altRe.exec(htmlContent);
1133
- if (linkMatch) {
1134
- globalCssOrigHref = linkMatch[1];
1135
- // Strip query string / fragment so the path resolves to the actual file
1136
- const cleanHref = linkMatch[1].split('?')[0].split('#')[0];
1137
- globalCssPath = path.resolve(htmlDir, cleanHref);
1138
- }
1139
- }
1140
-
1141
- if (globalCssPath && fs.existsSync(globalCssPath)) {
1142
- let cssContent = fs.readFileSync(globalCssPath, 'utf-8');
1143
- const cssMin = minifyCSS(cssContent);
1144
- const cssHash = crypto.createHash('sha256').update(cssMin).digest('hex').slice(0, 8);
1145
- const cssOutName = `global.${cssHash}.min.css`;
1146
-
1147
- // Clean previous global CSS builds
1148
- const cssCleanRe = /^global\.[a-f0-9]{8}\.min\.css$/;
1149
- for (const dir of [serverDir, localDir]) {
1150
- if (fs.existsSync(dir)) {
1151
- for (const f of fs.readdirSync(dir)) {
1152
- if (cssCleanRe.test(f)) fs.unlinkSync(path.join(dir, f));
1153
- }
1154
- }
1155
- }
1156
-
1157
- fs.writeFileSync(path.join(serverDir, cssOutName), cssMin, 'utf-8');
1158
- fs.writeFileSync(path.join(localDir, cssOutName), cssMin, 'utf-8');
1159
- globalCssHash = cssOutName;
1160
- console.log(` ${cssOutName} (${sizeKB(Buffer.from(cssMin))} KB)`);
1161
- }
1162
- }
1163
-
1164
- // Rewrite HTML to reference the minified bundle
1165
- const bundledFileSet = new Set(files);
1166
- // Skip the original unminified global CSS from static asset copying
1167
- if (globalCssPath) bundledFileSet.add(path.resolve(globalCssPath));
1168
- if (htmlFile) {
1169
- rewriteHtml(projectRoot, htmlFile, minFile, true, bundledFileSet, serverDir, localDir, globalCssOrigHref, globalCssHash);
1170
- }
1171
-
1172
- // Copy static asset directories (icons/, images/, fonts/, etc.)
1173
- if (!minimal) {
1174
- const appRoot = htmlAbs ? path.dirname(htmlAbs) : (targetDir || projectRoot);
1175
- copyStaticAssets(appRoot, serverDir, localDir, bundledFileSet);
1176
- }
1177
-
1178
- const elapsed = Date.now() - start;
1179
- console.log(` Done in ${elapsed}ms\n`);
1180
- }
1181
-
1182
- module.exports = bundleApp;
1183
- module.exports.stripModuleSyntax = stripModuleSyntax;
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
+ // 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;
29
+ let resolved = path.resolve(path.dirname(fromFile), specifier);
30
+ if (!path.extname(resolved) && fs.existsSync(resolved + '.js')) {
31
+ resolved += '.js';
32
+ }
33
+ return resolved;
34
+ }
35
+
36
+ /** Extract import specifiers from a source file. */
37
+ function extractImports(code) {
38
+ // Only scan the import preamble (before the first top-level `export`)
39
+ // so that code examples inside exported template strings are not
40
+ // mistaken for real imports.
41
+ const exportStart = code.search(/^export\b/m);
42
+ const preamble = exportStart > -1 ? code.slice(0, exportStart) : code;
43
+
44
+ const specifiers = [];
45
+ let m;
46
+ const fromRe = /\bfrom\s+['"]([^'"]+)['"]/g;
47
+ while ((m = fromRe.exec(preamble)) !== null) specifiers.push(m[1]);
48
+ const sideRe = /^\s*import\s+['"]([^'"]+)['"]\s*;?\s*$/gm;
49
+ while ((m = sideRe.exec(preamble)) !== null) {
50
+ if (!specifiers.includes(m[1])) specifiers.push(m[1]);
51
+ }
52
+
53
+ // Also capture re-exports anywhere in the file:
54
+ // export { x } from '...' export * from '...'
55
+ const reExportRe = /^\s*export\s+(?:\{[^}]*\}|\*)\s*from\s+['"]([^'"]+)['"]/gm;
56
+ while ((m = reExportRe.exec(code)) !== null) {
57
+ if (!specifiers.includes(m[1])) specifiers.push(m[1]);
58
+ }
59
+ return specifiers;
60
+ }
61
+
62
+ /** Walk the import graph - topological sort (leaves first). */
63
+ function walkImportGraph(entry) {
64
+ const visited = new Set();
65
+ const order = [];
66
+
67
+ function visit(file) {
68
+ const abs = path.resolve(file);
69
+ if (visited.has(abs)) return;
70
+ visited.add(abs);
71
+
72
+ if (!fs.existsSync(abs)) {
73
+ console.warn(` ⚠ Missing file: ${abs}`);
74
+ return;
75
+ }
76
+
77
+ const code = fs.readFileSync(abs, 'utf-8');
78
+ const imports = extractImports(code);
79
+ for (const spec of imports) {
80
+ const resolved = resolveImport(spec, abs);
81
+ if (resolved) visit(resolved);
82
+ }
83
+ order.push(abs);
84
+ }
85
+
86
+ visit(entry);
87
+ return order;
88
+ }
89
+
90
+ /**
91
+ * Strip ES module import/export syntax, keeping declarations.
92
+ * Exported const/let are converted to var so they hoist past the per-module
93
+ * block scope and remain accessible to downstream modules.
94
+ * Exported function/class are converted to var assignments for the same reason.
95
+ * Non-exported declarations stay as-is and remain block-scoped (private).
96
+ *
97
+ * Template literals are temporarily hidden via a character-level scanner
98
+ * (handles nested backtick expressions) so that code examples inside
99
+ * backtick strings aren't accidentally rewritten.
100
+ */
101
+ function stripModuleSyntax(code) {
102
+ // -- Hide template literals (supports nesting) -------------------------
103
+ const templates = [];
104
+
105
+ function scanTemplateLiteral(str, start) {
106
+ let i = start + 1; // skip opening backtick
107
+ while (i < str.length) {
108
+ const ch = str[i];
109
+ if (ch === '\\') { i += 2; continue; }
110
+ if (ch === '`') { return i + 1; } // end of template
111
+ if (ch === '$' && str[i + 1] === '{') {
112
+ i += 2; // skip ${
113
+ let depth = 1;
114
+ while (i < str.length && depth > 0) {
115
+ const c = str[i];
116
+ if (c === '{') { depth++; i++; }
117
+ else if (c === '}') { depth--; i++; }
118
+ else if (c === '`') { i = scanTemplateLiteral(str, i); }
119
+ else if (c === "'" || c === '"') { i = skipString(str, i); }
120
+ else if (c === '/' && str[i + 1] === '/') { while (i < str.length && str[i] !== '\n') i++; }
121
+ else if (c === '/' && str[i + 1] === '*') { i += 2; while (i < str.length - 1 && !(str[i] === '*' && str[i + 1] === '/')) i++; i += 2; }
122
+ else { i++; }
123
+ }
124
+ continue;
125
+ }
126
+ i++;
127
+ }
128
+ return i;
129
+ }
130
+
131
+ function skipString(str, start) {
132
+ const q = str[start];
133
+ let i = start + 1;
134
+ while (i < str.length) {
135
+ if (str[i] === '\\') { i += 2; continue; }
136
+ if (str[i] === q) { return i + 1; }
137
+ i++;
138
+ }
139
+ return i;
140
+ }
141
+
142
+ let hidden = '';
143
+ let pos = 0;
144
+ while (pos < code.length) {
145
+ const ch = code[pos];
146
+ if (ch === "'" || ch === '"') {
147
+ const end = skipString(code, pos);
148
+ hidden += code.substring(pos, end);
149
+ pos = end;
150
+ } else if (ch === '/' && code[pos + 1] === '/') {
151
+ let end = pos;
152
+ while (end < code.length && code[end] !== '\n') end++;
153
+ hidden += code.substring(pos, end);
154
+ pos = end;
155
+ } else if (ch === '/' && code[pos + 1] === '*') {
156
+ let end = pos + 2;
157
+ while (end < code.length - 1 && !(code[end] === '*' && code[end + 1] === '/')) end++;
158
+ end += 2;
159
+ hidden += code.substring(pos, end);
160
+ pos = end;
161
+ } else if (ch === '`') {
162
+ const end = scanTemplateLiteral(code, pos);
163
+ templates.push(code.substring(pos, end));
164
+ hidden += `__TPL_${templates.length - 1}__`;
165
+ pos = end;
166
+ } else {
167
+ hidden += ch;
168
+ pos++;
169
+ }
170
+ }
171
+
172
+ // -- Apply import / export transforms -----------------------------------
173
+ code = hidden;
174
+ code = code.replace(/^\s*import\s+[\s\S]*?from\s+['"].*?['"];?\s*$/gm, '');
175
+ code = code.replace(/^\s*import\s+['"].*?['"];?\s*$/gm, '');
176
+ code = code.replace(/^(\s*)export\s+default\s+/gm, '$1');
177
+ // Convert exported const/let/var to var (hoists past block scope)
178
+ code = code.replace(/^(\s*)export\s+(const|let|var)\s/gm, '$1var ');
179
+ // Convert exported function/async function to var assignment (hoists past block scope)
180
+ code = code.replace(/^(\s*)export\s+async\s+function\s+(\w+)/gm, '$1var $2 = async function $2');
181
+ code = code.replace(/^(\s*)export\s+function\s+(\w+)/gm, '$1var $2 = function $2');
182
+ // Convert exported class to var assignment
183
+ code = code.replace(/^(\s*)export\s+class\s+(\w+)/gm, '$1var $2 = class $2');
184
+ // Collect names from bare export blocks: export { a, b, c };
185
+ // These names are declared elsewhere (function/const/let) and need to be
186
+ // hoisted past the per-module block scope. We collect them here and
187
+ // the caller converts their declarations to var-based forms.
188
+ const bareExportNames = [];
189
+ code = code.replace(/^\s*export\s*\{([^}]+)\};?\s*$/gm, (_, names) => {
190
+ for (const n of names.split(',')) {
191
+ const parts = n.trim().split(/\s+as\s+/);
192
+ if (parts[0]) bareExportNames.push({ local: parts[0].trim(), exported: (parts[1] || parts[0]).trim() });
193
+ }
194
+ return '';
195
+ });
196
+
197
+ // For every bare-exported name, convert its block-scoped declaration to a
198
+ // var-based form so it hoists past the per-module { } wrapper:
199
+ // function foo(…) { … } → var foo = function foo(…) { … }
200
+ // async function foo(…) → var foo = async function foo(…)
201
+ // const/let foo = … → var foo = …
202
+ for (const { local } of bareExportNames) {
203
+ const fnRe = new RegExp(`^(\\s*)function\\s+${local}\\s*\\(`, 'gm');
204
+ code = code.replace(fnRe, `$1var ${local} = function ${local}(`);
205
+ const asyncFnRe = new RegExp(`^(\\s*)async\\s+function\\s+${local}\\s*\\(`, 'gm');
206
+ code = code.replace(asyncFnRe, `$1var ${local} = async function ${local}(`);
207
+ const constLetRe = new RegExp(`^(\\s*)(const|let)\\s+${local}\\b`, 'gm');
208
+ code = code.replace(constLetRe, `$1var ${local}`);
209
+ }
210
+
211
+ // Create aliases for "export { local as exported }" where the names differ
212
+ for (const { local, exported } of bareExportNames) {
213
+ if (exported !== local) {
214
+ code += `\nvar ${exported} = ${local};`;
215
+ }
216
+ }
217
+
218
+ // -- Restore template literals ------------------------------------------
219
+ code = code.replace(/__TPL_(\d+)__/g, (_, i) => templates[i]);
220
+ return { code, bareExportNames };
221
+ }
222
+
223
+ /** Replace import.meta.url with a runtime equivalent. */
224
+ function replaceImportMeta(code, filePath, projectRoot) {
225
+ if (!code.includes('import.meta')) return code;
226
+ const rel = path.relative(projectRoot, filePath).replace(/\\/g, '/');
227
+ return code.replace(/import\.meta\.url/g, `(new URL('${rel}', document.baseURI).href)`);
228
+ }
229
+
230
+ /**
231
+ * Rewrite templateUrl / styleUrl relative paths to project-relative paths.
232
+ * In a bundled IIFE, caller-base detection returns undefined (all stack
233
+ * frames point to the single bundle file), so relative URLs like
234
+ * 'contacts.html' never resolve to their original directory. By rewriting
235
+ * them to the same project-relative keys used in window.__zqInline, the
236
+ * runtime inline-resource lookup succeeds without a network fetch.
237
+ */
238
+ function rewriteResourceUrls(code, filePath, projectRoot) {
239
+ const fileDir = path.dirname(filePath);
240
+ return code.replace(
241
+ /((?:templateUrl|styleUrl)\s*:\s*)(['"])([^'"]+)\2/g,
242
+ (match, prefix, quote, url) => {
243
+ if (url.startsWith('/') || url.includes('://')) return match;
244
+ const abs = path.resolve(fileDir, url);
245
+ // Only rewrite if the file actually exists - avoids mangling code examples
246
+ if (!fs.existsSync(abs)) return match;
247
+ const rel = path.relative(projectRoot, abs).replace(/\\/g, '/');
248
+ return `${prefix}${quote}${rel}${quote}`;
249
+ }
250
+ );
251
+ }
252
+
253
+ /**
254
+ * Minify HTML for inlining - strips indentation and collapses whitespace
255
+ * between tags. Preserves content inside <pre>, <code>, and <textarea>
256
+ * blocks verbatim so syntax-highlighted code samples survive.
257
+ */
258
+ function minifyHTML(html) {
259
+ const preserved = [];
260
+ // Protect <pre>…</pre> and <textarea>…</textarea> (multiline code blocks)
261
+ html = html.replace(/<(pre|textarea)(\b[^>]*)>[\s\S]*?<\/\1>/gi, m => {
262
+ preserved.push(m);
263
+ return `\x00P${preserved.length - 1}\x00`;
264
+ });
265
+ // Strip HTML comments
266
+ html = html.replace(/<!--[\s\S]*?-->/g, '');
267
+ // Collapse runs of whitespace (newlines + indentation) to a single space
268
+ html = html.replace(/\s{2,}/g, ' ');
269
+ // Remove space between tags: "> <" → "><"
270
+ // but preserve a space when an inline element is involved (a, span, strong, em, b, i, code, small, sub, sup, abbr, label)
271
+ html = html.replace(/>\s+</g, (m, offset) => {
272
+ const before = html.slice(Math.max(0, offset - 80), offset + 1);
273
+ const after = html.slice(offset + m.length - 1, offset + m.length + 40);
274
+ const inlineTags = /\b(a|span|strong|em|b|i|code|small|sub|sup|abbr|label)\b/i;
275
+ const closingInline = /<\/\s*(a|span|strong|em|b|i|code|small|sub|sup|abbr|label)\s*>$/i.test(before);
276
+ const openingInline = /^<(a|span|strong|em|b|i|code|small|sub|sup|abbr|label)[\s>]/i.test(after);
277
+ return (closingInline || openingInline) ? '> <' : '><';
278
+ });
279
+ // Remove spaces inside opening tags: <tag attr = "val" > → <tag attr="val">
280
+ html = html.replace(/ *\/ *>/g, '/>');
281
+ html = html.replace(/ *= */g, '=');
282
+ // Trim
283
+ html = html.trim();
284
+ // Restore preserved blocks
285
+ html = html.replace(/\x00P(\d+)\x00/g, (_, i) => preserved[+i]);
286
+ return html;
287
+ }
288
+
289
+ /**
290
+ * Minify CSS for inlining - strips comments, collapses whitespace,
291
+ * removes unnecessary spaces around punctuation.
292
+ */
293
+ function minifyCSS(css) {
294
+ // Strip block comments
295
+ css = css.replace(/\/\*[\s\S]*?\*\//g, '');
296
+ // Collapse whitespace
297
+ css = css.replace(/\s{2,}/g, ' ');
298
+ // Remove spaces around { } ; , (but NOT : — pseudo-selectors like :not() need the preceding space)
299
+ css = css.replace(/\s*([{};,])\s*/g, '$1');
300
+ // Remove space after : (safe in both selectors and declarations)
301
+ css = css.replace(/:\s+/g, ':');
302
+ // Remove trailing semicolons before }
303
+ css = css.replace(/;}/g, '}');
304
+ return css.trim();
305
+ }
306
+
307
+ /**
308
+ * Heuristic: is the next '/' the start of a regex literal (vs division)?
309
+ * Used by minifyTemplateLiterals to avoid misinterpreting backticks
310
+ * inside regex patterns as template literal delimiters.
311
+ */
312
+ function _isRegexCtxTpl(out) {
313
+ let end = out.length - 1;
314
+ while (end >= 0 && (out[end] === ' ' || out[end] === '\t' || out[end] === '\n' || out[end] === '\r')) end--;
315
+ if (end < 0) return true;
316
+ const last = out[end];
317
+ if ('=({[,;:!&|?~+-*/%^>'.includes(last)) return true;
318
+ const tail = out.substring(Math.max(0, end - 7), end + 1);
319
+ for (const kw of ['return', 'typeof', 'case', 'in', 'delete', 'void', 'throw', 'new']) {
320
+ if (tail.endsWith(kw)) {
321
+ const pos = end - kw.length;
322
+ if (pos < 0 || !/[a-zA-Z0-9_$]/.test(out[pos])) return true;
323
+ }
324
+ }
325
+ return false;
326
+ }
327
+
328
+ /**
329
+ * Walk JS source and minify the HTML/CSS inside template literals.
330
+ * Handles ${…} interpolations (with nesting) and preserves <pre> blocks.
331
+ * Only trims whitespace sequences that appear between/around HTML tags.
332
+ */
333
+ function minifyTemplateLiterals(code) {
334
+ let out = '';
335
+ let i = 0;
336
+ while (i < code.length) {
337
+ const ch = code[i];
338
+
339
+ // Regular string: copy verbatim
340
+ if (ch === '"' || ch === "'") {
341
+ const q = ch; out += ch; i++;
342
+ while (i < code.length) {
343
+ if (code[i] === '\\') { out += code[i] + (code[i + 1] || ''); i += 2; continue; }
344
+ out += code[i];
345
+ if (code[i] === q) { i++; break; }
346
+ i++;
347
+ }
348
+ continue;
349
+ }
350
+
351
+ // Line comment: copy verbatim
352
+ if (ch === '/' && code[i + 1] === '/') {
353
+ while (i < code.length && code[i] !== '\n') out += code[i++];
354
+ continue;
355
+ }
356
+ // Block comment: copy verbatim
357
+ if (ch === '/' && code[i + 1] === '*') {
358
+ out += '/*'; i += 2;
359
+ while (i < code.length && !(code[i] === '*' && code[i + 1] === '/')) out += code[i++];
360
+ out += '*/'; i += 2;
361
+ continue;
362
+ }
363
+
364
+ // Regex literal: copy verbatim (prevents backticks inside regex from
365
+ // being mistaken for template literals, e.g. /`([^`]+)`/g)
366
+ if (ch === '/' && _isRegexCtxTpl(out)) {
367
+ out += ch; i++;
368
+ let inCharClass = false;
369
+ while (i < code.length) {
370
+ const rc = code[i];
371
+ if (rc === '\\') { out += rc + (code[i + 1] || ''); i += 2; continue; }
372
+ if (rc === '[') inCharClass = true;
373
+ if (rc === ']') inCharClass = false;
374
+ out += rc; i++;
375
+ if (rc === '/' && !inCharClass) {
376
+ while (i < code.length && /[gimsuy]/.test(code[i])) { out += code[i]; i++; }
377
+ break;
378
+ }
379
+ }
380
+ continue;
381
+ }
382
+
383
+ // Template literal: extract, minify HTML, and emit
384
+ if (ch === '`') {
385
+ out += _minifyTemplate(code, i);
386
+ // Advance past the template
387
+ i = _skipTemplateLiteral(code, i);
388
+ continue;
389
+ }
390
+
391
+ out += ch; i++;
392
+ }
393
+ return out;
394
+ }
395
+
396
+ /** Extract a full template literal (handling nested ${…}) and return it minified. */
397
+ function _minifyTemplate(code, start) {
398
+ const end = _skipTemplateLiteral(code, start);
399
+ const raw = code.substring(start, end);
400
+ // Only minify templates that contain HTML tags or CSS rules
401
+ if (/<\w/.test(raw)) return _collapseTemplateWS(raw);
402
+ if (/[{};]\s/.test(raw) && /[.#\w-]+\s*\{/.test(raw)) return _collapseTemplateCSS(raw);
403
+ return raw;
404
+ }
405
+
406
+ /** Return index past the closing backtick of a template starting at `start`. */
407
+ function _skipTemplateLiteral(code, start) {
408
+ let i = start + 1; // skip opening backtick
409
+ let depth = 0;
410
+ while (i < code.length) {
411
+ if (code[i] === '\\') { i += 2; continue; }
412
+ if (code[i] === '$' && code[i + 1] === '{') { depth++; i += 2; continue; }
413
+ if (depth > 0) {
414
+ if (code[i] === '{') { depth++; i++; continue; }
415
+ if (code[i] === '}') { depth--; i++; continue; }
416
+ if (code[i] === '`') { i = _skipTemplateLiteral(code, i); continue; }
417
+ if (code[i] === '"' || code[i] === "'") {
418
+ const q = code[i]; i++;
419
+ while (i < code.length) {
420
+ if (code[i] === '\\') { i += 2; continue; }
421
+ if (code[i] === q) { i++; break; }
422
+ i++;
423
+ }
424
+ continue;
425
+ }
426
+ i++; continue;
427
+ }
428
+ if (code[i] === '`') { i++; return i; } // closing backtick
429
+ i++;
430
+ }
431
+ return i;
432
+ }
433
+
434
+ /**
435
+ * Collapse whitespace in the text portions of an HTML template literal,
436
+ * preserving ${…} expressions, <pre> blocks, and inline text spacing.
437
+ */
438
+ function _collapseTemplateWS(tpl) {
439
+ // Build array of segments: text portions vs ${…} expressions
440
+ const segments = [];
441
+ let i = 1; // skip opening backtick
442
+ let text = '';
443
+ while (i < tpl.length - 1) { // stop before closing backtick
444
+ if (tpl[i] === '\\') { text += tpl[i] + (tpl[i + 1] || ''); i += 2; continue; }
445
+ if (tpl[i] === '$' && tpl[i + 1] === '{') {
446
+ if (text) { segments.push({ type: 'text', val: text }); text = ''; }
447
+ // Collect the full expression
448
+ let depth = 1; let expr = '${'; i += 2;
449
+ while (i < tpl.length - 1 && depth > 0) {
450
+ if (tpl[i] === '\\') { expr += tpl[i] + (tpl[i + 1] || ''); i += 2; continue; }
451
+ if (tpl[i] === '{') depth++;
452
+ if (tpl[i] === '}') depth--;
453
+ if (depth > 0) { expr += tpl[i]; i++; } else { expr += '}'; i++; }
454
+ }
455
+ segments.push({ type: 'expr', val: expr });
456
+ continue;
457
+ }
458
+ text += tpl[i]; i++;
459
+ }
460
+ if (text) segments.push({ type: 'text', val: text });
461
+
462
+ // Minify text segments (collapse whitespace between/around tags)
463
+ for (let s = 0; s < segments.length; s++) {
464
+ if (segments[s].type !== 'text') continue;
465
+ let t = segments[s].val;
466
+ // Protect <pre>…</pre> regions
467
+ const preserved = [];
468
+ t = t.replace(/<pre(\b[^>]*)>[\s\S]*?<\/pre>/gi, m => {
469
+ preserved.push(m);
470
+ return `\x00P${preserved.length - 1}\x00`;
471
+ });
472
+ // Collapse whitespace runs that touch a < or > but preserve a space
473
+ // when an inline element boundary is involved
474
+ t = t.replace(/>\s{2,}/g, (m, offset) => {
475
+ const before = t.slice(Math.max(0, offset - 80), offset + 1);
476
+ if (/<\/\s*(a|span|strong|em|b|i|code|small|sub|sup|abbr|label)\s*>$/i.test(before)) return '> ';
477
+ return '>';
478
+ });
479
+ t = t.replace(/\s{2,}</g, (m, offset) => {
480
+ const after = t.slice(offset + m.length - 1, offset + m.length + 40);
481
+ if (/^<(a|span|strong|em|b|i|code|small|sub|sup|abbr|label)[\s>]/i.test(after)) return ' <';
482
+ return '<';
483
+ });
484
+ // Collapse other multi-whitespace runs to a single space
485
+ t = t.replace(/\s{2,}/g, ' ');
486
+ // Restore <pre> blocks
487
+ t = t.replace(/\x00P(\d+)\x00/g, (_, idx) => preserved[+idx]);
488
+ segments[s].val = t;
489
+ }
490
+
491
+ return '`' + segments.map(s => s.val).join('') + '`';
492
+ }
493
+
494
+ /**
495
+ * Collapse CSS whitespace inside a template literal (styles: `…`).
496
+ * Uses the same segment-splitting approach as _collapseTemplateWS so
497
+ * ${…} expressions are preserved untouched.
498
+ */
499
+ function _collapseTemplateCSS(tpl) {
500
+ const segments = [];
501
+ let i = 1; let text = '';
502
+ while (i < tpl.length - 1) {
503
+ if (tpl[i] === '\\') { text += tpl[i] + (tpl[i + 1] || ''); i += 2; continue; }
504
+ if (tpl[i] === '$' && tpl[i + 1] === '{') {
505
+ if (text) { segments.push({ type: 'text', val: text }); text = ''; }
506
+ let depth = 1; let expr = '${'; i += 2;
507
+ while (i < tpl.length - 1 && depth > 0) {
508
+ if (tpl[i] === '\\') { expr += tpl[i] + (tpl[i + 1] || ''); i += 2; continue; }
509
+ if (tpl[i] === '{') depth++;
510
+ if (tpl[i] === '}') depth--;
511
+ if (depth > 0) { expr += tpl[i]; i++; } else { expr += '}'; i++; }
512
+ }
513
+ segments.push({ type: 'expr', val: expr });
514
+ continue;
515
+ }
516
+ text += tpl[i]; i++;
517
+ }
518
+ if (text) segments.push({ type: 'text', val: text });
519
+
520
+ for (let s = 0; s < segments.length; s++) {
521
+ if (segments[s].type !== 'text') continue;
522
+ let t = segments[s].val;
523
+ t = t.replace(/\/\*[\s\S]*?\*\//g, '');
524
+ t = t.replace(/\s{2,}/g, ' ');
525
+ t = t.replace(/\s*([{};,])\s*/g, '$1');
526
+ t = t.replace(/:\s+/g, ':');
527
+ t = t.replace(/;}/g, '}');
528
+ segments[s].val = t;
529
+ }
530
+ return '`' + segments.map(s => s.val).join('') + '`';
531
+ }
532
+
533
+ /**
534
+ * Scan bundled source files for external resource references
535
+ * (templateUrl, styleUrl) and return a map of
536
+ * { relativePath: fileContent } for inlining.
537
+ */
538
+ function collectInlineResources(files, projectRoot) {
539
+ const inlineMap = {};
540
+
541
+ for (const file of files) {
542
+ const code = fs.readFileSync(file, 'utf-8');
543
+ const fileDir = path.dirname(file);
544
+
545
+ // styleUrl:
546
+ const styleUrlRe = /styleUrl\s*:\s*['"]([^'"]+)['"]/g;
547
+ let styleMatch;
548
+ while ((styleMatch = styleUrlRe.exec(code)) !== null) {
549
+ const stylePath = path.join(fileDir, styleMatch[1]);
550
+ if (fs.existsSync(stylePath)) {
551
+ const relKey = path.relative(projectRoot, stylePath).replace(/\\/g, '/');
552
+ inlineMap[relKey] = fs.readFileSync(stylePath, 'utf-8');
553
+ }
554
+ }
555
+
556
+ // templateUrl:
557
+ const tmplMatch = code.match(/templateUrl\s*:\s*['"]([^'"]+)['"]/);
558
+ if (tmplMatch) {
559
+ const tmplPath = path.join(fileDir, tmplMatch[1]);
560
+ if (fs.existsSync(tmplPath)) {
561
+ const relKey = path.relative(projectRoot, tmplPath).replace(/\\/g, '/');
562
+ inlineMap[relKey] = fs.readFileSync(tmplPath, 'utf-8');
563
+ }
564
+ } else if (/templateUrl\s*:/.test(code)) {
565
+ // Dynamic templateUrl (e.g. Object.fromEntries, computed map) -
566
+ // inline all .html files in the component's directory tree so
567
+ // the runtime __zqInline lookup can resolve them by suffix.
568
+ (function scanHtml(dir) {
569
+ try {
570
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
571
+ const full = path.join(dir, entry.name);
572
+ if (entry.isFile() && entry.name.endsWith('.html')) {
573
+ const relKey = path.relative(projectRoot, full).replace(/\\/g, '/');
574
+ if (!inlineMap[relKey]) {
575
+ inlineMap[relKey] = fs.readFileSync(full, 'utf-8');
576
+ }
577
+ } else if (entry.isDirectory()) {
578
+ scanHtml(full);
579
+ }
580
+ }
581
+ } catch { /* permission error - skip */ }
582
+ })(fileDir);
583
+ }
584
+ }
585
+
586
+ // Minify inlined resources by type
587
+ for (const key of Object.keys(inlineMap)) {
588
+ if (key.endsWith('.html')) inlineMap[key] = minifyHTML(inlineMap[key]);
589
+ else if (key.endsWith('.css')) inlineMap[key] = minifyCSS(inlineMap[key]);
590
+ }
591
+
592
+ return inlineMap;
593
+ }
594
+
595
+ /**
596
+ * Auto-detect the app entry point.
597
+ *
598
+ * Strategy - ordered by precedence (first match wins):
599
+ * 1. HTML discovery: index.html first, then other .html files
600
+ * (root level + one directory deep).
601
+ * 2. Within each HTML file, prefer a module <script> whose src
602
+ * resolves to app.js, then fall back to the first module
603
+ * <script> tag regardless of name.
604
+ * 3. JS file scan: look for $.router first (entry point by design),
605
+ * then $.mount / $.store / mountAll.
606
+ * 4. Convention fallback paths.
607
+ */
608
+ function detectEntry(projectRoot) {
609
+ // Matches all <script type="module" src="…"> tags (global)
610
+ const moduleScriptReG = /<script[^>]+type\s*=\s*["']module["'][^>]+src\s*=\s*["']([^"']+)["']/g;
611
+
612
+ // Collect HTML files: root level + one directory deep
613
+ const htmlFiles = [];
614
+ for (const entry of fs.readdirSync(projectRoot, { withFileTypes: true })) {
615
+ if (entry.isFile() && entry.name.endsWith('.html')) {
616
+ htmlFiles.push(path.join(projectRoot, entry.name));
617
+ } else if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules' && entry.name !== 'dist') {
618
+ const sub = path.join(projectRoot, entry.name);
619
+ try {
620
+ for (const child of fs.readdirSync(sub, { withFileTypes: true })) {
621
+ if (child.isFile() && child.name.endsWith('.html')) {
622
+ htmlFiles.push(path.join(sub, child.name));
623
+ }
624
+ }
625
+ } catch { /* permission error - skip */ }
626
+ }
627
+ }
628
+
629
+ // Sort: index.html first (at any depth), then alphabetical
630
+ htmlFiles.sort((a, b) => {
631
+ const aIsIndex = path.basename(a) === 'index.html' ? 0 : 1;
632
+ const bIsIndex = path.basename(b) === 'index.html' ? 0 : 1;
633
+ if (aIsIndex !== bIsIndex) return aIsIndex - bIsIndex;
634
+ return a.localeCompare(b);
635
+ });
636
+
637
+ // 1. Parse module <script> tags from HTML files (index.html evaluated first).
638
+ // Within each file prefer a src ending in app.js, else the first module tag.
639
+ for (const htmlPath of htmlFiles) {
640
+ const html = fs.readFileSync(htmlPath, 'utf-8');
641
+ const htmlDir = path.dirname(htmlPath);
642
+
643
+ let appJsEntry = null; // src that resolves to app.js
644
+ let firstEntry = null; // first module script src
645
+ let m;
646
+ moduleScriptReG.lastIndex = 0;
647
+ while ((m = moduleScriptReG.exec(html)) !== null) {
648
+ const resolved = path.resolve(htmlDir, m[1]);
649
+ if (!fs.existsSync(resolved)) continue;
650
+ if (!firstEntry) firstEntry = resolved;
651
+ if (path.basename(resolved) === 'app.js') { appJsEntry = resolved; break; }
652
+ }
653
+
654
+ if (appJsEntry) return appJsEntry;
655
+ if (firstEntry) return firstEntry;
656
+ }
657
+
658
+ // 2. Search JS files for entry-point patterns.
659
+ // Pass 1 - $.router (the canonical entry point).
660
+ // Pass 2 - $.mount, $.store, mountAll (component-level, lower confidence).
661
+ const routerRe = /\$\.router\s*\(/;
662
+ const otherRe = /\$\.(mount|store)\s*\(|mountAll\s*\(/;
663
+
664
+ function collectJS(dir, depth = 0) {
665
+ const results = [];
666
+ if (depth > 2) return results;
667
+ try {
668
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
669
+ if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'dist') continue;
670
+ const full = path.join(dir, entry.name);
671
+ if (entry.isFile() && entry.name.endsWith('.js')) {
672
+ results.push(full);
673
+ } else if (entry.isDirectory()) {
674
+ results.push(...collectJS(full, depth + 1));
675
+ }
676
+ }
677
+ } catch { /* skip */ }
678
+ return results;
679
+ }
680
+
681
+ const jsFiles = collectJS(projectRoot);
682
+
683
+ // Pass 1: $.router
684
+ for (const file of jsFiles) {
685
+ const code = fs.readFileSync(file, 'utf-8');
686
+ if (routerRe.test(code)) return file;
687
+ }
688
+ // Pass 2: other entry-point signals
689
+ for (const file of jsFiles) {
690
+ const code = fs.readFileSync(file, 'utf-8');
691
+ if (otherRe.test(code)) return file;
692
+ }
693
+
694
+ // 3. Convention fallbacks
695
+ const fallbacks = ['app/app.js', 'scripts/app.js', 'src/app.js', 'js/app.js', 'app.js', 'main.js'];
696
+ for (const f of fallbacks) {
697
+ const fp = path.join(projectRoot, f);
698
+ if (fs.existsSync(fp)) return fp;
699
+ }
700
+ return null;
701
+ }
702
+
703
+ // ---------------------------------------------------------------------------
704
+ // HTML rewriting
705
+ // ---------------------------------------------------------------------------
706
+
707
+ /**
708
+ * Rewrite an HTML file to replace the module <script> with the bundle.
709
+ * Produces two variants:
710
+ * server/index.html - <base href="/"> for SPA deep routes
711
+ * local/index.html - relative paths for file:// access
712
+ */
713
+ function rewriteHtml(projectRoot, htmlRelPath, bundleFile, includeLib, bundledFiles, serverDir, localDir, globalCssOrigHref, globalCssHash) {
714
+ const htmlPath = path.resolve(projectRoot, htmlRelPath);
715
+ if (!fs.existsSync(htmlPath)) {
716
+ console.warn(` ⚠ HTML file not found: ${htmlRelPath}`);
717
+ return;
718
+ }
719
+
720
+ const htmlDir = path.dirname(htmlPath);
721
+ let html = fs.readFileSync(htmlPath, 'utf-8');
722
+
723
+ // Collect asset references from HTML
724
+ const assetRe = /(?:<(?:link|script|img)[^>]*?\s(?:src|href)\s*=\s*["'])([^"']+)["']/gi;
725
+ const assets = new Set();
726
+ let m;
727
+ while ((m = assetRe.exec(html)) !== null) {
728
+ const ref = m[1];
729
+ if (ref.startsWith('http') || ref.startsWith('//') || ref.startsWith('data:') || ref.startsWith('#')) continue;
730
+ const refAbs = path.resolve(htmlDir, ref);
731
+ if (bundledFiles && bundledFiles.has(refAbs)) continue;
732
+ if (includeLib && /zquery(?:\.min)?\.js$/i.test(ref)) continue;
733
+ assets.add(ref);
734
+ }
735
+
736
+ // Also scan the bundled JS for src/href references to local assets
737
+ const bundleContent = fs.existsSync(bundleFile) ? fs.readFileSync(bundleFile, 'utf-8') : '';
738
+ const jsAssetRe = /\b(?:src|href)\s*=\s*["\\]*["']([^"']+\.(?:svg|png|jpg|jpeg|gif|webp|ico|woff2?|ttf|eot|mp4|webm))["']/gi;
739
+ while ((m = jsAssetRe.exec(bundleContent)) !== null) {
740
+ const ref = m[1];
741
+ if (ref.startsWith('http') || ref.startsWith('//') || ref.startsWith('data:')) continue;
742
+ if (!assets.has(ref)) {
743
+ const refAbs = path.resolve(htmlDir, ref);
744
+ if (fs.existsSync(refAbs)) assets.add(ref);
745
+ }
746
+ }
747
+
748
+ // For any referenced asset directories, copy all sibling files too
749
+ const assetDirs = new Set();
750
+ for (const asset of assets) {
751
+ const dir = path.dirname(asset);
752
+ if (dir && dir !== '.') assetDirs.add(dir);
753
+ }
754
+ for (const dir of assetDirs) {
755
+ const absDirPath = path.resolve(htmlDir, dir);
756
+ if (fs.existsSync(absDirPath) && fs.statSync(absDirPath).isDirectory()) {
757
+ for (const child of fs.readdirSync(absDirPath)) {
758
+ const childRel = path.join(dir, child).replace(/\\/g, '/');
759
+ const childAbs = path.join(absDirPath, child);
760
+ if (fs.statSync(childAbs).isFile() && !assets.has(childRel)) {
761
+ assets.add(childRel);
762
+ }
763
+ }
764
+ }
765
+ }
766
+
767
+ // Copy assets into both dist dirs
768
+ let copiedCount = 0;
769
+ for (const asset of assets) {
770
+ const srcFile = path.resolve(htmlDir, asset);
771
+ if (!fs.existsSync(srcFile)) continue;
772
+ for (const distDir of [serverDir, localDir]) {
773
+ const destFile = path.join(distDir, asset);
774
+ const destDir = path.dirname(destFile);
775
+ if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
776
+ fs.copyFileSync(srcFile, destFile);
777
+ }
778
+ copiedCount++;
779
+ }
780
+
781
+ // Copy CSS-referenced assets (fonts, images in url())
782
+ for (const asset of assets) {
783
+ const srcFile = path.resolve(htmlDir, asset);
784
+ if (!fs.existsSync(srcFile) || !asset.endsWith('.css')) continue;
785
+ const cssContent = fs.readFileSync(srcFile, 'utf-8');
786
+ const urlRe = /url\(\s*["']?([^"')]+?)["']?\s*\)/g;
787
+ let cm;
788
+ while ((cm = urlRe.exec(cssContent)) !== null) {
789
+ const ref = cm[1];
790
+ if (ref.startsWith('data:') || ref.startsWith('http') || ref.startsWith('//')) continue;
791
+ const cssSrcDir = path.dirname(srcFile);
792
+ const assetSrc = path.resolve(cssSrcDir, ref);
793
+ if (!fs.existsSync(assetSrc)) continue;
794
+ for (const distDir of [serverDir, localDir]) {
795
+ const cssDestDir = path.dirname(path.join(distDir, asset));
796
+ const assetDest = path.resolve(cssDestDir, ref);
797
+ if (!fs.existsSync(assetDest)) {
798
+ const dir = path.dirname(assetDest);
799
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
800
+ fs.copyFileSync(assetSrc, assetDest);
801
+ }
802
+ }
803
+ copiedCount++;
804
+ }
805
+ }
806
+
807
+ const bundleRel = path.relative(serverDir, bundleFile).replace(/\\/g, '/');
808
+
809
+ html = html.replace(
810
+ /<script\s+type\s*=\s*["']module["']\s+src\s*=\s*["'][^"']+["']\s*>\s*<\/script>/gi,
811
+ `<script defer src="${bundleRel}"></script>`
812
+ );
813
+
814
+ if (includeLib) {
815
+ html = html.replace(
816
+ /\s*<script\s+src\s*=\s*["'][^"']*zquery(?:\.min)?\.js["']\s*>\s*<\/script>/gi,
817
+ ''
818
+ );
819
+ }
820
+
821
+ // Rewrite global CSS link to hashed version
822
+ if (globalCssOrigHref && globalCssHash) {
823
+ const escapedHref = globalCssOrigHref.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
824
+ const cssLinkRe = new RegExp(
825
+ `(<link[^>]+href\\s*=\\s*["'])${escapedHref}(["'][^>]*>)`, 'i'
826
+ );
827
+ html = html.replace(cssLinkRe, `$1${globalCssHash}$2`);
828
+ }
829
+
830
+ const serverHtml = html;
831
+ const localHtml = html.replace(/<base\s+href\s*=\s*["']\/["'][^>]*>\s*\n?\s*/i, '');
832
+
833
+ const htmlName = path.basename(htmlRelPath);
834
+ fs.writeFileSync(path.join(serverDir, htmlName), serverHtml, 'utf-8');
835
+ fs.writeFileSync(path.join(localDir, htmlName), localHtml, 'utf-8');
836
+ console.log(` ✓ server/${htmlName} (with <base href="/">)`);
837
+ console.log(` ✓ local/${htmlName} (relative paths, file:// ready)`);
838
+ console.log(` ✓ Copied ${copiedCount} asset(s) into both dist dirs`);
839
+ }
840
+
841
+ // ---------------------------------------------------------------------------
842
+ // Static asset copying
843
+ // ---------------------------------------------------------------------------
844
+
845
+ /**
846
+ * Copy the entire app directory into both dist/server and dist/local,
847
+ * skipping only build outputs, tooling dirs, and the app/ source dir.
848
+ * This ensures all static assets (assets/, icons/, images/, fonts/,
849
+ * manifests, etc.) are available in the built output without maintaining
850
+ * a fragile whitelist.
851
+ */
852
+ function copyStaticAssets(appRoot, serverDir, localDir, bundledFiles) {
853
+ const SKIP_DIRS = new Set(['dist', 'node_modules', '.git', '.vscode']);
854
+
855
+ // Case-insensitive match for App/ directory (contains bundled source)
856
+ function isAppDir(name) {
857
+ return name.toLowerCase() === 'app';
858
+ }
859
+
860
+ let copiedCount = 0;
861
+
862
+ function copyEntry(srcPath, relPath) {
863
+ const stat = fs.statSync(srcPath);
864
+ if (stat.isDirectory()) {
865
+ const dirName = path.basename(srcPath);
866
+ if (SKIP_DIRS.has(dirName) || isAppDir(dirName)) return;
867
+ for (const child of fs.readdirSync(srcPath)) {
868
+ copyEntry(path.join(srcPath, child), path.join(relPath, child));
869
+ }
870
+ } else {
871
+ if (bundledFiles && bundledFiles.has(path.resolve(srcPath))) return;
872
+ for (const distDir of [serverDir, localDir]) {
873
+ const dest = path.join(distDir, relPath);
874
+ if (fs.existsSync(dest)) continue; // already copied by rewriteHtml
875
+ const dir = path.dirname(dest);
876
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
877
+ fs.copyFileSync(srcPath, dest);
878
+ }
879
+ copiedCount++;
880
+ }
881
+ }
882
+
883
+ for (const entry of fs.readdirSync(appRoot, { withFileTypes: true })) {
884
+ if (entry.isDirectory()) {
885
+ if (SKIP_DIRS.has(entry.name) || isAppDir(entry.name)) continue;
886
+ copyEntry(path.join(appRoot, entry.name), entry.name);
887
+ } else {
888
+ copyEntry(path.join(appRoot, entry.name), entry.name);
889
+ }
890
+ }
891
+
892
+ if (copiedCount > 0) {
893
+ console.log(` \u2713 Copied ${copiedCount} additional static asset(s) into both dist dirs`);
894
+ }
895
+ }
896
+
897
+ // ---------------------------------------------------------------------------
898
+ // Main bundleApp function
899
+ // ---------------------------------------------------------------------------
900
+
901
+ function bundleApp() {
902
+ const projectRoot = process.cwd();
903
+ const minimal = flag('minimal', 'm');
904
+ const globalCssOverride = option('global-css', null, null);
905
+
906
+ // Entry point - positional arg (directory or file) or auto-detection
907
+ let entry = null;
908
+ let targetDir = null;
909
+ for (let i = 1; i < args.length; i++) {
910
+ if (!args[i].startsWith('-') && args[i - 1] !== '-o' && args[i - 1] !== '--out' && args[i - 1] !== '--index') {
911
+ const resolved = path.resolve(projectRoot, args[i]);
912
+ if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
913
+ targetDir = resolved;
914
+ entry = detectEntry(resolved);
915
+ } else {
916
+ entry = resolved;
917
+ }
918
+ break;
919
+ }
920
+ }
921
+ if (!entry) entry = detectEntry(projectRoot);
922
+
923
+ if (!entry || !fs.existsSync(entry)) {
924
+ console.error(`\n \u2717 Could not find entry file.`);
925
+ console.error(` Provide an app directory: zquery bundle my-app/`);
926
+ console.error(` Or pass a direct entry file: zquery bundle my-app/scripts/main.js\n`);
927
+ process.exit(1);
928
+ }
929
+
930
+ const outPath = option('out', 'o', null);
931
+
932
+ // Auto-detect HTML file
933
+ let htmlFile = option('index', 'i', null);
934
+ let htmlAbs = htmlFile ? path.resolve(projectRoot, htmlFile) : null;
935
+ if (!htmlFile) {
936
+ // Strategy: first look for index.html walking up from entry, then
937
+ // scan for any .html that references the entry via a module script tag.
938
+ const htmlCandidates = [];
939
+ let entryDir = path.dirname(entry);
940
+ while (entryDir.length >= projectRoot.length) {
941
+ htmlCandidates.push(path.join(entryDir, 'index.html'));
942
+ const parent = path.dirname(entryDir);
943
+ if (parent === entryDir) break;
944
+ entryDir = parent;
945
+ }
946
+ htmlCandidates.push(path.join(projectRoot, 'index.html'));
947
+ htmlCandidates.push(path.join(projectRoot, 'public/index.html'));
948
+ for (const candidate of htmlCandidates) {
949
+ if (fs.existsSync(candidate)) {
950
+ htmlAbs = candidate;
951
+ htmlFile = path.relative(projectRoot, candidate);
952
+ break;
953
+ }
954
+ }
955
+
956
+ // If no index.html found, scan for any .html file that references
957
+ // the entry point (supports home.html, app.html, etc.)
958
+ if (!htmlAbs) {
959
+ const searchRoot = targetDir || projectRoot;
960
+ const htmlScan = [];
961
+ for (const e of fs.readdirSync(searchRoot, { withFileTypes: true })) {
962
+ if (e.isFile() && e.name.endsWith('.html')) {
963
+ htmlScan.push(path.join(searchRoot, e.name));
964
+ } else if (e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules' && e.name !== 'dist') {
965
+ try {
966
+ for (const child of fs.readdirSync(path.join(searchRoot, e.name), { withFileTypes: true })) {
967
+ if (child.isFile() && child.name.endsWith('.html')) {
968
+ htmlScan.push(path.join(searchRoot, e.name, child.name));
969
+ }
970
+ }
971
+ } catch { /* skip */ }
972
+ }
973
+ }
974
+ // Prefer the HTML file that references our entry via a module script
975
+ const moduleScriptRe = /<script[^>]+type\s*=\s*["']module["'][^>]+src\s*=\s*["']([^"']+)["']/g;
976
+ for (const hp of htmlScan) {
977
+ const content = fs.readFileSync(hp, 'utf-8');
978
+ let m;
979
+ moduleScriptRe.lastIndex = 0;
980
+ while ((m = moduleScriptRe.exec(content)) !== null) {
981
+ const resolved = path.resolve(path.dirname(hp), m[1]);
982
+ if (resolved === path.resolve(entry)) {
983
+ htmlAbs = hp;
984
+ htmlFile = path.relative(projectRoot, hp);
985
+ break;
986
+ }
987
+ }
988
+ if (htmlAbs) break;
989
+ }
990
+ // Last resort: use the first .html found
991
+ if (!htmlAbs && htmlScan.length > 0) {
992
+ htmlAbs = htmlScan[0];
993
+ htmlFile = path.relative(projectRoot, htmlScan[0]);
994
+ }
995
+ }
996
+ }
997
+
998
+ // Output directory
999
+ const entryRel = path.relative(projectRoot, entry);
1000
+ const entryName = path.basename(entry, '.js');
1001
+ let baseDistDir;
1002
+ if (outPath) {
1003
+ const resolved = path.resolve(projectRoot, outPath);
1004
+ if (outPath.endsWith('/') || outPath.endsWith('\\') || !path.extname(outPath) ||
1005
+ (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory())) {
1006
+ baseDistDir = resolved;
1007
+ } else {
1008
+ baseDistDir = path.dirname(resolved);
1009
+ }
1010
+ } else if (htmlAbs) {
1011
+ baseDistDir = path.join(path.dirname(htmlAbs), 'dist');
1012
+ } else {
1013
+ baseDistDir = path.join(projectRoot, 'dist');
1014
+ }
1015
+
1016
+ const serverDir = path.join(baseDistDir, 'server');
1017
+ const localDir = path.join(baseDistDir, 'local');
1018
+
1019
+ console.log(`\n zQuery App Bundler`);
1020
+ console.log(` Entry: ${entryRel}`);
1021
+ console.log(` Output: ${path.relative(projectRoot, baseDistDir)}/server/ & local/`);
1022
+ console.log(` Library: embedded`);
1023
+ console.log(` HTML: ${htmlFile || 'not found (no HTML detected)'}`);
1024
+ if (minimal) console.log(` Mode: minimal (HTML + JS + global CSS only)`);
1025
+ console.log('');
1026
+
1027
+ // ------ doBuild (inlined) ------
1028
+ const start = Date.now();
1029
+
1030
+ // Clean previous dist outputs for a fresh build
1031
+ for (const dir of [serverDir, localDir]) {
1032
+ if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true });
1033
+ }
1034
+ fs.mkdirSync(serverDir, { recursive: true });
1035
+ fs.mkdirSync(localDir, { recursive: true });
1036
+
1037
+ const files = walkImportGraph(entry);
1038
+ console.log(` Resolved ${files.length} module(s):`);
1039
+ files.forEach(f => console.log(` • ${path.relative(projectRoot, f)}`));
1040
+
1041
+ const sections = files.map(file => {
1042
+ let code = fs.readFileSync(file, 'utf-8');
1043
+ const stripped = stripModuleSyntax(code);
1044
+ code = stripped.code;
1045
+ code = replaceImportMeta(code, file, projectRoot);
1046
+ code = rewriteResourceUrls(code, file, projectRoot);
1047
+ code = minifyTemplateLiterals(code);
1048
+ const rel = path.relative(projectRoot, file);
1049
+ return `// --- ${rel} ${'-'.repeat(Math.max(1, 60 - rel.length))}\n{\n${code.trim()}\n}`;
1050
+ });
1051
+
1052
+ // Embed zquery.min.js
1053
+ let libSection = '';
1054
+ {
1055
+ // __dirname is cli/commands/, so the package root is two levels up.
1056
+ // When installed via `npm install zero-query`, pkgRoot lives inside the
1057
+ // user's node_modules/ — in that case we never rebuild and just embed the
1058
+ // pre-built dist/zquery.min.js that ships with the package.
1059
+ const pkgRoot = path.resolve(__dirname, '..', '..');
1060
+ const pkgSrcDir = path.join(pkgRoot, 'src');
1061
+ const pkgMinFile = path.join(pkgRoot, 'dist', 'zquery.min.js');
1062
+ const isInstalled = /[\\/]node_modules[\\/]/.test(pkgRoot);
1063
+
1064
+ if (!isInstalled && fs.existsSync(pkgSrcDir) && fs.existsSync(path.join(pkgRoot, 'index.js'))) {
1065
+ console.log(`\n Building library from source...`);
1066
+ const prevCwd = process.cwd();
1067
+ try {
1068
+ process.chdir(pkgRoot);
1069
+ buildLibrary();
1070
+ } finally {
1071
+ process.chdir(prevCwd);
1072
+ }
1073
+ }
1074
+
1075
+ const htmlDir = htmlAbs ? path.dirname(htmlAbs) : null;
1076
+
1077
+ // Case-insensitive search for Assets/ directory
1078
+ function findAssetsDir(root) {
1079
+ try {
1080
+ for (const e of fs.readdirSync(root, { withFileTypes: true })) {
1081
+ if (e.isDirectory() && e.name.toLowerCase() === 'assets') return path.join(root, e.name);
1082
+ }
1083
+ } catch { /* skip */ }
1084
+ return null;
1085
+ }
1086
+
1087
+ const assetsDir = findAssetsDir(htmlDir || projectRoot);
1088
+ const altAssetsDir = htmlDir ? findAssetsDir(projectRoot) : null;
1089
+
1090
+ const libCandidates = [
1091
+ path.join(pkgRoot, 'dist/zquery.min.js'),
1092
+ assetsDir && path.join(assetsDir, 'scripts/zquery.min.js'),
1093
+ altAssetsDir && path.join(altAssetsDir, 'scripts/zquery.min.js'),
1094
+ htmlDir && path.join(htmlDir, 'scripts/vendor/zquery.min.js'),
1095
+ htmlDir && path.join(htmlDir, 'vendor/zquery.min.js'),
1096
+ path.join(projectRoot, 'scripts/vendor/zquery.min.js'),
1097
+ path.join(projectRoot, 'vendor/zquery.min.js'),
1098
+ path.join(projectRoot, 'lib/zquery.min.js'),
1099
+ path.join(projectRoot, 'dist/zquery.min.js'),
1100
+ path.join(projectRoot, 'zquery.min.js'),
1101
+ ].filter(Boolean);
1102
+ const libPath = libCandidates.find(p => fs.existsSync(p));
1103
+
1104
+ if (libPath) {
1105
+ const libBytes = fs.statSync(libPath).size;
1106
+ libSection = `// --- zquery.min.js (library) ${'-'.repeat(34)}\n${fs.readFileSync(libPath, 'utf-8').trim()}\n\n`
1107
+ + `// --- Build-time metadata ${'-'.repeat(50)}\nif(typeof $!=="undefined"){$.meta=Object.assign($.meta||{},{libSize:${libBytes}});}\n\n`;
1108
+ console.log(` Embedded library from ${path.relative(projectRoot, libPath)} (${(libBytes / 1024).toFixed(1)} KB)`);
1109
+ } else {
1110
+ console.warn(`\n ⚠ Could not find zquery.min.js anywhere`);
1111
+ console.warn(` Place zquery.min.js in assets/scripts/, vendor/, lib/, or dist/`);
1112
+ }
1113
+ }
1114
+
1115
+ const banner = `/**\n * App bundle - built by zQuery CLI\n * Entry: ${entryRel}\n * ${new Date().toISOString()}\n */`;
1116
+
1117
+ // Inline resources
1118
+ const inlineMap = collectInlineResources(files, projectRoot);
1119
+ let inlineSection = '';
1120
+ if (Object.keys(inlineMap).length > 0) {
1121
+ const entries = Object.entries(inlineMap).map(([key, content]) => {
1122
+ const escaped = content
1123
+ .replace(/\\/g, '\\\\')
1124
+ .replace(/'/g, "\\'")
1125
+ .replace(/\n/g, '\\n')
1126
+ .replace(/\r/g, '');
1127
+ return ` '${key}': '${escaped}'`;
1128
+ });
1129
+ inlineSection = `// --- Inlined resources (file:// support) ${'-'.repeat(20)}\nwindow.__zqInline = {\n${entries.join(',\n')}\n};\n\n`;
1130
+ console.log(`\n Inlined ${Object.keys(inlineMap).length} external resource(s)`);
1131
+ }
1132
+
1133
+ const bundle = `${banner}\n(function() {\n 'use strict';\n\n${libSection}${inlineSection}${sections.join('\n\n')}\n\n})();\n`;
1134
+
1135
+ // Content-hashed filenames
1136
+ const contentHash = crypto.createHash('sha256').update(bundle).digest('hex').slice(0, 8);
1137
+ const minBase = `z-${entryName}.${contentHash}.min.js`;
1138
+
1139
+ // Clean previous builds
1140
+ const escName = entryName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1141
+ const cleanRe = new RegExp(`^z-${escName}\\.[a-f0-9]{8}\\.(?:min\\.)?js$`);
1142
+ for (const dir of [serverDir, localDir]) {
1143
+ if (fs.existsSync(dir)) {
1144
+ for (const f of fs.readdirSync(dir)) {
1145
+ if (cleanRe.test(f)) fs.unlinkSync(path.join(dir, f));
1146
+ }
1147
+ }
1148
+ }
1149
+
1150
+ // Write minified bundle
1151
+ const minFile = path.join(serverDir, minBase);
1152
+ fs.writeFileSync(minFile, minify(bundle, banner), 'utf-8');
1153
+ fs.copyFileSync(minFile, path.join(localDir, minBase));
1154
+
1155
+ console.log(`\n ✓ ${minBase} (${sizeKB(fs.readFileSync(minFile))} KB)`);
1156
+
1157
+ // ------------------------------------------------------------------
1158
+ // Global CSS bundling - extract from index.html <link> or --global-css
1159
+ // ------------------------------------------------------------------
1160
+ let globalCssHash = null;
1161
+ let globalCssOrigHref = null;
1162
+ let globalCssPath = null;
1163
+ if (htmlAbs) {
1164
+ const htmlContent = fs.readFileSync(htmlAbs, 'utf-8');
1165
+ const htmlDir = path.dirname(htmlAbs);
1166
+
1167
+ // Determine global CSS path: --global-css flag overrides, else first <link rel="stylesheet"> in HTML
1168
+ globalCssPath = null;
1169
+ if (globalCssOverride) {
1170
+ globalCssPath = path.resolve(projectRoot, globalCssOverride);
1171
+ // Reconstruct relative href for HTML rewriting
1172
+ globalCssOrigHref = path.relative(htmlDir, globalCssPath).replace(/\\/g, '/');
1173
+ } else {
1174
+ const linkRe = /<link[^>]+rel\s*=\s*["']stylesheet["'][^>]+href\s*=\s*["']([^"']+)["']/gi;
1175
+ const altRe = /<link[^>]+href\s*=\s*["']([^"']+)["'][^>]+rel\s*=\s*["']stylesheet["']/gi;
1176
+ let linkMatch = linkRe.exec(htmlContent) || altRe.exec(htmlContent);
1177
+ if (linkMatch) {
1178
+ globalCssOrigHref = linkMatch[1];
1179
+ // Strip query string / fragment so the path resolves to the actual file
1180
+ const cleanHref = linkMatch[1].split('?')[0].split('#')[0];
1181
+ globalCssPath = path.resolve(htmlDir, cleanHref);
1182
+ }
1183
+ }
1184
+
1185
+ if (globalCssPath && fs.existsSync(globalCssPath)) {
1186
+ let cssContent = fs.readFileSync(globalCssPath, 'utf-8');
1187
+ const cssMin = minifyCSS(cssContent);
1188
+ const cssHash = crypto.createHash('sha256').update(cssMin).digest('hex').slice(0, 8);
1189
+ const cssOutName = `global.${cssHash}.min.css`;
1190
+
1191
+ // Clean previous global CSS builds
1192
+ const cssCleanRe = /^global\.[a-f0-9]{8}\.min\.css$/;
1193
+ for (const dir of [serverDir, localDir]) {
1194
+ if (fs.existsSync(dir)) {
1195
+ for (const f of fs.readdirSync(dir)) {
1196
+ if (cssCleanRe.test(f)) fs.unlinkSync(path.join(dir, f));
1197
+ }
1198
+ }
1199
+ }
1200
+
1201
+ fs.writeFileSync(path.join(serverDir, cssOutName), cssMin, 'utf-8');
1202
+ fs.writeFileSync(path.join(localDir, cssOutName), cssMin, 'utf-8');
1203
+ globalCssHash = cssOutName;
1204
+ console.log(` ✓ ${cssOutName} (${sizeKB(Buffer.from(cssMin))} KB)`);
1205
+ }
1206
+ }
1207
+
1208
+ // Rewrite HTML to reference the minified bundle
1209
+ const bundledFileSet = new Set(files);
1210
+ // Skip the original unminified global CSS from static asset copying
1211
+ if (globalCssPath) bundledFileSet.add(path.resolve(globalCssPath));
1212
+ if (htmlFile) {
1213
+ rewriteHtml(projectRoot, htmlFile, minFile, true, bundledFileSet, serverDir, localDir, globalCssOrigHref, globalCssHash);
1214
+ }
1215
+
1216
+ // Copy static asset directories (icons/, images/, fonts/, etc.)
1217
+ if (!minimal) {
1218
+ const appRoot = htmlAbs ? path.dirname(htmlAbs) : (targetDir || projectRoot);
1219
+ copyStaticAssets(appRoot, serverDir, localDir, bundledFileSet);
1220
+ }
1221
+
1222
+ const elapsed = Date.now() - start;
1223
+ console.log(` Done in ${elapsed}ms\n`);
1224
+ }
1225
+
1226
+ module.exports = bundleApp;
1227
+ module.exports.stripModuleSyntax = stripModuleSyntax;
1228
+ module.exports.minifyTemplateLiterals = minifyTemplateLiterals;