zero-query 1.0.5 → 1.1.1

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.
@@ -87,14 +87,137 @@ function walkImportGraph(entry) {
87
87
  return order;
88
88
  }
89
89
 
90
- /** Strip ES module import/export syntax, keeping declarations. */
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
+ */
91
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;
92
174
  code = code.replace(/^\s*import\s+[\s\S]*?from\s+['"].*?['"];?\s*$/gm, '');
93
175
  code = code.replace(/^\s*import\s+['"].*?['"];?\s*$/gm, '');
94
176
  code = code.replace(/^(\s*)export\s+default\s+/gm, '$1');
95
- code = code.replace(/^(\s*)export\s+(const|let|var|function|class|async\s+function)\s/gm, '$1$2 ');
96
- code = code.replace(/^\s*export\s*\{[\s\S]*?\};?\s*$/gm, '');
97
- return code;
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 };
98
221
  }
99
222
 
100
223
  /** Replace import.meta.url with a runtime equivalent. */
@@ -181,6 +304,27 @@ function minifyCSS(css) {
181
304
  return css.trim();
182
305
  }
183
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
+
184
328
  /**
185
329
  * Walk JS source and minify the HTML/CSS inside template literals.
186
330
  * Handles ${…} interpolations (with nesting) and preserves <pre> blocks.
@@ -217,6 +361,25 @@ function minifyTemplateLiterals(code) {
217
361
  continue;
218
362
  }
219
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
+
220
383
  // Template literal: extract, minify HTML, and emit
221
384
  if (ch === '`') {
222
385
  out += _minifyTemplate(code, i);
@@ -381,8 +544,8 @@ function collectInlineResources(files, projectRoot) {
381
544
 
382
545
  // styleUrl:
383
546
  const styleUrlRe = /styleUrl\s*:\s*['"]([^'"]+)['"]/g;
384
- const styleMatch = styleUrlRe.exec(code);
385
- if (styleMatch) {
547
+ let styleMatch;
548
+ while ((styleMatch = styleUrlRe.exec(code)) !== null) {
386
549
  const stylePath = path.join(fileDir, styleMatch[1]);
387
550
  if (fs.existsSync(stylePath)) {
388
551
  const relKey = path.relative(projectRoot, stylePath).replace(/\\/g, '/');
@@ -877,12 +1040,13 @@ function bundleApp() {
877
1040
 
878
1041
  const sections = files.map(file => {
879
1042
  let code = fs.readFileSync(file, 'utf-8');
880
- code = stripModuleSyntax(code);
1043
+ const stripped = stripModuleSyntax(code);
1044
+ code = stripped.code;
881
1045
  code = replaceImportMeta(code, file, projectRoot);
882
1046
  code = rewriteResourceUrls(code, file, projectRoot);
883
1047
  code = minifyTemplateLiterals(code);
884
1048
  const rel = path.relative(projectRoot, file);
885
- return `// --- ${rel} ${'-'.repeat(Math.max(1, 60 - rel.length))}\n${code.trim()}`;
1049
+ return `// --- ${rel} ${'-'.repeat(Math.max(1, 60 - rel.length))}\n{\n${code.trim()}\n}`;
886
1050
  });
887
1051
 
888
1052
  // Embed zquery.min.js
@@ -1056,3 +1220,5 @@ function bundleApp() {
1056
1220
  }
1057
1221
 
1058
1222
  module.exports = bundleApp;
1223
+ module.exports.stripModuleSyntax = stripModuleSyntax;
1224
+ module.exports.minifyTemplateLiterals = minifyTemplateLiterals;
@@ -4,6 +4,13 @@
4
4
  * Creates the zero-http app, serves static files, injects the
5
5
  * error-overlay snippet into HTML responses, and manages the
6
6
  * SSE connection pool for live-reload events.
7
+ *
8
+ * Uses zero-http middleware:
9
+ * - helmet() → security headers (relaxed CSP for dev inline scripts)
10
+ * - compress() → brotli/gzip/deflate response compression
11
+ * - cors() → allow cross-origin requests in development
12
+ * - serveStatic() → static file serving with ETag & Cache-Control
13
+ * - SSE with keepAlive, retry, pad for proxy compatibility
7
14
  */
8
15
 
9
16
  'use strict';
@@ -22,6 +29,7 @@ class SSEPool {
22
29
  this._clients = new Set();
23
30
  }
24
31
 
32
+ /** @param {import('zero-http').SSEStream} sse */
25
33
  add(sse) {
26
34
  this._clients.add(sse);
27
35
  sse.on('close', () => this._clients.delete(sse));
@@ -33,6 +41,9 @@ class SSEPool {
33
41
  }
34
42
  }
35
43
 
44
+ /** Number of connected SSE clients. */
45
+ get size() { return this._clients.size; }
46
+
36
47
  closeAll() {
37
48
  for (const sse of this._clients) {
38
49
  try { sse.close(); } catch { /* ignore */ }
@@ -92,14 +103,54 @@ async function createServer({ root, htmlEntry, port, noIntercept }) {
92
103
  zeroHttp = require('zero-http');
93
104
  }
94
105
 
95
- const { createApp, static: serveStatic } = zeroHttp;
106
+ const {
107
+ createApp,
108
+ static: serveStatic,
109
+ helmet,
110
+ compress,
111
+ cors,
112
+ debug,
113
+ } = zeroHttp;
114
+
115
+ debug.level('silent');
96
116
 
97
117
  const app = createApp();
98
118
  const pool = new SSEPool();
99
119
 
120
+ // ---- Security headers (dev-friendly CSP) ----
121
+ app.use(helmet({
122
+ contentSecurityPolicy: {
123
+ directives: {
124
+ defaultSrc: ["'self'"],
125
+ scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"],
126
+ styleSrc: ["'self'", "'unsafe-inline'"],
127
+ imgSrc: ["'self'", 'data:', 'blob:'],
128
+ connectSrc: ["'self'", 'ws:', 'wss:'],
129
+ fontSrc: ["'self'", 'data:'],
130
+ },
131
+ },
132
+ // SPA dev server runs over plain HTTP
133
+ hsts: false,
134
+ // Allow framing for devtools panel
135
+ frameguard: false,
136
+ }));
137
+
138
+ // ---- CORS (allow cross-origin during development) ----
139
+ app.use(cors());
140
+
141
+ // ---- Compression (brotli > gzip > deflate) ----
142
+ app.use(compress({
143
+ threshold: 1024,
144
+ }));
145
+
100
146
  // ---- SSE endpoint ----
101
147
  app.get('/__zq_reload', (req, res) => {
102
- const sse = res.sse({ keepAlive: 30000, keepAliveComment: 'ping' });
148
+ const sse = res.sse({
149
+ keepAlive: 30000,
150
+ keepAliveComment: 'ping',
151
+ retry: 3000,
152
+ pad: 2048,
153
+ });
103
154
  pool.add(sse);
104
155
  });
105
156
 
@@ -157,7 +208,10 @@ async function createServer({ root, htmlEntry, port, noIntercept }) {
157
208
  });
158
209
 
159
210
  function listen(cb) {
160
- app.listen(port, cb);
211
+ const server = app.listen(port, cb);
212
+ server.keepAliveTimeout = 65000;
213
+ server.headersTimeout = 66000;
214
+ return server;
161
215
  }
162
216
 
163
217
  return { app, pool, listen };
@@ -4,7 +4,7 @@
4
4
  * to the contacts-page component by zQuery.
5
5
  */
6
6
 
7
- /* ── Toolbar ── */
7
+ /* -- Toolbar -- */
8
8
  .ct-bar {
9
9
  display: flex;
10
10
  align-items: center;
@@ -75,7 +75,7 @@
75
75
  box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.1);
76
76
  }
77
77
 
78
- /* ── Filter chips ── */
78
+ /* -- Filter chips -- */
79
79
  .ct-chips {
80
80
  display: flex;
81
81
  gap: 0.35rem;
@@ -108,7 +108,7 @@
108
108
  font-weight: 600;
109
109
  }
110
110
 
111
- /* ── Buttons ── */
111
+ /* -- Buttons -- */
112
112
  .ct-btn {
113
113
  display: inline-flex;
114
114
  align-items: center;
@@ -166,7 +166,7 @@
166
166
  font-size: 0.78rem;
167
167
  }
168
168
 
169
- /* ── Contact Grid ── */
169
+ /* -- Contact Grid -- */
170
170
  .ct-grid {
171
171
  display: grid;
172
172
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
@@ -314,7 +314,7 @@
314
314
  opacity: 1;
315
315
  }
316
316
 
317
- /* ── Empty ── */
317
+ /* -- Empty -- */
318
318
  .ct-empty-card {
319
319
  text-align: center;
320
320
  padding: 3rem 1rem;
@@ -405,7 +405,7 @@
405
405
  color: #fff;
406
406
  }
407
407
 
408
- /* ── Add-contact modal heading ── */
408
+ /* -- Add-contact modal heading -- */
409
409
  .ct-modal-heading {
410
410
  padding: 1.25rem 1.5rem 0;
411
411
  }
@@ -421,7 +421,7 @@
421
421
  color: var(--text-muted);
422
422
  }
423
423
 
424
- /* ── Form inside modal ── */
424
+ /* -- Form inside modal -- */
425
425
  .ct-form {
426
426
  display: flex;
427
427
  flex-direction: column;
@@ -488,7 +488,7 @@
488
488
  padding-top: 0.35rem;
489
489
  }
490
490
 
491
- /* ── Detail modal: profile ── */
491
+ /* -- Detail modal: profile -- */
492
492
  .ct-modal-profile {
493
493
  display: flex;
494
494
  flex-direction: column;
@@ -608,7 +608,7 @@
608
608
  font-weight: 500;
609
609
  }
610
610
 
611
- /* ── Responsive ── */
611
+ /* -- Responsive -- */
612
612
  @media (max-width: 768px) {
613
613
  .ct-bar {
614
614
  flex-direction: column;
@@ -102,7 +102,7 @@
102
102
  .pg-vpill:hover { border-color: var(--accent); color: var(--text); }
103
103
  .pg-vpill.on { background: var(--accent); color: #fff; border-color: var(--accent); }
104
104
 
105
- /* ── Responsive ── */
105
+ /* -- Responsive -- */
106
106
  @media (max-width: 768px) {
107
107
  .pg-variant-box { min-width: 0; flex: 1 1 100%; }
108
108
  .pg-variant-row { flex-direction: column; }
@@ -9,7 +9,7 @@
9
9
  <p class="subtitle">Interactive UI patterns - event modifiers, animations, reactive bindings, and plugins.</p>
10
10
  </div>
11
11
 
12
- <!-- ── Interaction Patterns ──────────────────────────── -->
12
+ <!-- -- Interaction Patterns ---------------------------- -->
13
13
  <div class="pg-section">Interaction Patterns</div>
14
14
 
15
15
  <div class="card" style="position:relative;z-index:2;">
@@ -44,7 +44,7 @@
44
44
  </div>
45
45
  </div>
46
46
 
47
- <!-- ── Animations ──────────────────────────────────── -->
47
+ <!-- -- Animations ------------------------------------ -->
48
48
  <div class="pg-section">Animations</div>
49
49
 
50
50
  <div class="card">
@@ -81,7 +81,7 @@
81
81
  </div>
82
82
  </div>
83
83
 
84
- <!-- ── Reactive Bindings ───────────────────────────── -->
84
+ <!-- -- Reactive Bindings ----------------------------- -->
85
85
  <div class="pg-section">Reactive Bindings</div>
86
86
 
87
87
  <div class="card-grid">
@@ -123,7 +123,7 @@
123
123
  </div>
124
124
  </div>
125
125
 
126
- <!-- ── Keyboard ──────────────────────────────────── -->
126
+ <!-- -- Keyboard ------------------------------------ -->
127
127
  <div class="pg-section">Keyboard</div>
128
128
 
129
129
  <div class="card">
@@ -140,7 +140,7 @@
140
140
  </div>
141
141
  </div>
142
142
 
143
- <!-- ── Plugin System ───────────────────────────────── -->
143
+ <!-- -- Plugin System --------------------------------- -->
144
144
  <div class="pg-section">Plugin System</div>
145
145
 
146
146
  <div class="card">
@@ -10,7 +10,7 @@
10
10
  // z-skip - morph opt-out
11
11
  // templateUrl / styleUrl - external files
12
12
 
13
- // ── $.fn Plugins ─────────────────────────────────────────────────
13
+ // -- $.fn Plugins -------------------------------------------------
14
14
  $.fn.highlight = function (color = 'var(--accent)') {
15
15
  this.css({ boxShadow: `0 0 0 3px ${color}`, transition: 'box-shadow .3s ease' });
16
16
  const self = this;
@@ -87,7 +87,7 @@
87
87
  border: 1px solid rgba(88,166,255,.1); }
88
88
  .tk-stat-val { font-weight: 700; color: var(--accent); }
89
89
 
90
- /* ── Responsive: tabs ── */
90
+ /* -- Responsive: tabs -- */
91
91
  @media (max-width: 768px) {
92
92
  .tk-tabs { flex-wrap: wrap; }
93
93
  .tk-tab { flex: 1 1 auto; min-width: 0; padding: .5rem .6rem; font-size: .8rem; }
@@ -16,7 +16,7 @@
16
16
  <button class="tk-tab {{activeTab === 'store' ? 'active' : ''}}" @click="setTab('store')">◈ Store</button>
17
17
  </div>
18
18
 
19
- <!-- ── HTTP Tab ───────────────────────────────────── -->
19
+ <!-- -- HTTP Tab ------------------------------------- -->
20
20
  <div z-if="activeTab === 'http'">
21
21
  <div class="card">
22
22
  <h3>CRUD Operations</h3>
@@ -61,7 +61,7 @@
61
61
  </div>
62
62
  </div>
63
63
 
64
- <!-- ── Utilities Tab ──────────────────────────────── -->
64
+ <!-- -- Utilities Tab -------------------------------- -->
65
65
  <div z-else-if="activeTab === 'utils'">
66
66
  <div class="card">
67
67
  <h3>Utility Belt</h3>
@@ -98,7 +98,7 @@
98
98
  </div>
99
99
  </div>
100
100
 
101
- <!-- ── Store Tab ──────────────────────────────────── -->
101
+ <!-- -- Store Tab ------------------------------------ -->
102
102
  <div z-else>
103
103
  <div class="card" style="margin-bottom:.75rem;">
104
104
  <h3>Store Inspector</h3>
@@ -105,7 +105,7 @@ $.component('toolkit-page', {
105
105
  this.state.storeHistory = null;
106
106
  },
107
107
 
108
- /* ── HTTP demos ───────────────────────────────────────────── */
108
+ /* -- HTTP demos --------------------------------------------- */
109
109
 
110
110
  async doGet() {
111
111
  this._updateHttpMeta('GET', '/posts/1');
@@ -165,7 +165,7 @@ $.component('toolkit-page', {
165
165
  } finally { this.state.searchLoading = false; }
166
166
  },
167
167
 
168
- /* ── Utility demos ─────────────────────────────────────────── */
168
+ /* -- Utility demos ------------------------------------------- */
169
169
 
170
170
  runUtil(name) {
171
171
  this.state.activeUtil = name;
@@ -197,7 +197,7 @@ $.component('toolkit-page', {
197
197
  `$.memoize(fibonacci)`,
198
198
  ``,
199
199
  `fib(35) = ${result}`,
200
- `────────────────────────────`,
200
+ `----------------------------`,
201
201
  `No memoize: ${slowTime.toFixed(1)} ms`,
202
202
  `Memoized: ${fastTime.toFixed(3)} ms`,
203
203
  `Cached: ${cachedTime.toFixed(4)} ms ⚡`,
@@ -264,7 +264,7 @@ $.component('toolkit-page', {
264
264
  this.state.utilOutput = result;
265
265
  },
266
266
 
267
- /* ── Store demos ──────────────────────────────────────────── */
267
+ /* -- Store demos -------------------------------------------- */
268
268
 
269
269
  takeSnapshot() {
270
270
  this.state.storeSnap = JSON.stringify($.getStore('main').snapshot(), null, 2);
package/cli/utils.js CHANGED
@@ -149,7 +149,7 @@ function _minifyBody(code) {
149
149
  const ch = code[i];
150
150
  const nx = code[i + 1];
151
151
 
152
- // ── Regular string literal: copy verbatim ─────────────────
152
+ // -- Regular string literal: copy verbatim -----------------
153
153
  if (ch === '"' || ch === "'") {
154
154
  const q = ch;
155
155
  out += ch; i++;
@@ -162,14 +162,14 @@ function _minifyBody(code) {
162
162
  continue;
163
163
  }
164
164
 
165
- // ── Template literal: copy verbatim with ${…} nesting ───────
165
+ // -- Template literal: copy verbatim with ${…} nesting -------
166
166
  if (ch === '`') {
167
167
  const tpl = _copyTemplateLiteral(code, i);
168
168
  out += tpl.text; i = tpl.end;
169
169
  continue;
170
170
  }
171
171
 
172
- // ── Block comment: skip ─────────────────────────────────────
172
+ // -- Block comment: skip -------------------------------------
173
173
  if (ch === '/' && nx === '*') {
174
174
  i += 2;
175
175
  while (i < code.length && !(code[i] === '*' && code[i + 1] === '/')) i++;
@@ -177,14 +177,14 @@ function _minifyBody(code) {
177
177
  continue;
178
178
  }
179
179
 
180
- // ── Line comment: skip ──────────────────────────────────────
180
+ // -- Line comment: skip --------------------------------------
181
181
  if (ch === '/' && nx === '/') {
182
182
  i += 2;
183
183
  while (i < code.length && code[i] !== '\n') i++;
184
184
  continue;
185
185
  }
186
186
 
187
- // ── Regex literal: copy verbatim ────────────────────────────
187
+ // -- Regex literal: copy verbatim ----------------------------
188
188
  if (ch === '/') {
189
189
  if (_isRegexCtx(out)) {
190
190
  out += ch; i++;
@@ -204,11 +204,20 @@ function _minifyBody(code) {
204
204
  }
205
205
  }
206
206
 
207
- // ── Whitespace: collapse ────────────────────────────────────
207
+ // -- Whitespace: collapse ------------------------------------
208
208
  if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
209
- while (i < code.length && (code[i] === ' ' || code[i] === '\t' || code[i] === '\n' || code[i] === '\r')) i++;
209
+ let hasNewline = ch === '\n' || ch === '\r';
210
+ while (i < code.length && (code[i] === ' ' || code[i] === '\t' || code[i] === '\n' || code[i] === '\r')) {
211
+ if (code[i] === '\n' || code[i] === '\r') hasNewline = true;
212
+ i++;
213
+ }
210
214
  const before = out[out.length - 1];
211
215
  const after = code[i];
216
+ // After '}', a newline may be needed for ASI (e.g. var x=function(){}⏎var y).
217
+ // A space alone doesn't trigger ASI, so preserve ';\n' when '}' precedes
218
+ // an identifier-start character and the original whitespace had a newline.
219
+ const afterIsId = after && ((after >= 'a' && after <= 'z') || (after >= 'A' && after <= 'Z') || after === '_' || after === '$');
220
+ if (before === '}' && afterIsId && hasNewline) { out += '\n'; continue; }
212
221
  if (_needsSpace(before, after)) out += ' ';
213
222
  continue;
214
223
  }