@webjsdev/server 0.8.6 → 0.8.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.6",
3
+ "version": "0.8.7",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
package/src/auth.js CHANGED
@@ -86,7 +86,7 @@ function clearCookie(name) {
86
86
  // -- JWT --------------------------------------------------------------------
87
87
 
88
88
  /** @param {Record<string,unknown>} payload @param {string} secret */
89
- async function encodeJwt(payload, secret) {
89
+ export async function encodeJwt(payload, secret) {
90
90
  const h = b64url(enc.encode(JSON.stringify({ alg: 'HS256', typ: 'JWT' })));
91
91
  const p = b64url(enc.encode(JSON.stringify(payload)));
92
92
  const unsigned = `${h}.${p}`;
@@ -95,7 +95,7 @@ async function encodeJwt(payload, secret) {
95
95
  }
96
96
 
97
97
  /** @param {string} token @param {string} secret @returns {Promise<Record<string,unknown>|null>} */
98
- async function decodeJwt(token, secret) {
98
+ export async function decodeJwt(token, secret) {
99
99
  const parts = token.split('.');
100
100
  if (parts.length !== 3) return null;
101
101
  // `unb64url` → `atob` throws InvalidCharacterError on non-base64 input.
package/src/check.js CHANGED
@@ -114,6 +114,11 @@ export const RULES = [
114
114
  description:
115
115
  'Verifies the `.gitignore` exception for `.webjs/vendor/` is structurally correct via `git check-ignore`. The intended pattern is `.webjs/*` (NOT `.webjs/`) plus `!.webjs/vendor/` plus `!.webjs/vendor/**`. The common-looking pattern `.webjs/` excludes the directory itself, after which git cannot re-include children (gitignore semantics: a parent exclusion blocks child negations). Without this rule, an AI agent or human editor would silently break `webjs vendor pin` by simplifying the pattern; the failure is invisible until production. Rule fires when the working directory is a git repo and a `.gitignore` exists; skipped when neither is true.',
116
116
  },
117
+ {
118
+ name: 'no-browser-globals-in-render',
119
+ description:
120
+ 'Flags browser-only APIs used in a WebComponent constructor or render() method. The SSR pipeline instantiates the component (running the constructor) and calls render() to produce HTML, on a bare server-side class with no DOM. So a browser global (document, window, localStorage, sessionStorage, navigator, location, matchMedia, screen, history) or an HTMLElement instance member on `this` (attachShadow, shadowRoot, setAttribute, getAttribute, removeAttribute, dispatchEvent, classList, querySelector, querySelectorAll, getBoundingClientRect, focus, blur, scrollIntoView) touched there throws at SSR time (the isomorphic footgun). These belong in connectedCallback() or a lifecycle hook (firstUpdated/updated), which SSR never calls; seed first-paint defaults in the constructor only from server-known inputs (attributes, props). Conservative: only the constructor and render bodies are scanned (the methods SSR actually runs), and only direct references, so helper indirection is not flagged (the runtime SSR error covers that case).',
121
+ },
117
122
  ];
118
123
 
119
124
  /** Set of all known rule names for fast lookup. */
@@ -389,6 +394,72 @@ function findFieldInitializers(classBody, props) {
389
394
  return out;
390
395
  }
391
396
 
397
+ // Browser-only globals that are undefined during SSR (the server-side
398
+ // WebComponent base is a bare class with no DOM). High-confidence names only
399
+ // (unlikely to be ordinary local variables), so the rule stays low-noise.
400
+ const BROWSER_GLOBALS = [
401
+ 'document', 'window', 'localStorage', 'sessionStorage', 'navigator',
402
+ 'matchMedia', 'requestAnimationFrame', 'getComputedStyle',
403
+ 'IntersectionObserver', 'MutationObserver', 'ResizeObserver',
404
+ ];
405
+ // HTMLElement instance members that do not exist on the bare server class, so
406
+ // `this.<member>` throws (a method call) or is `undefined` (a property) at SSR.
407
+ const HTMLELEMENT_MEMBERS = [
408
+ 'attachShadow', 'shadowRoot', 'setAttribute', 'getAttribute',
409
+ 'removeAttribute', 'hasAttribute', 'dispatchEvent', 'classList',
410
+ 'querySelector', 'querySelectorAll', 'getBoundingClientRect',
411
+ 'focus', 'blur', 'scrollIntoView',
412
+ ];
413
+
414
+ /**
415
+ * Extract the body text of a named method from a (redacted) class body, or
416
+ * '' if absent. Handles `async`, a TS return-type annotation, and params.
417
+ * @param {string} classBody
418
+ * @param {string} name
419
+ */
420
+ function methodBodyOf(classBody, name) {
421
+ const re = new RegExp(`(?:^|[\\s;}])(?:async\\s+)?${name}\\s*\\([^)]*\\)\\s*(?::[^{]*)?\\{`, 'g');
422
+ const m = re.exec(classBody);
423
+ if (!m) return '';
424
+ const open = classBody.indexOf('{', m.index + m[0].length - 1);
425
+ if (open === -1) return '';
426
+ const close = matchClosingBrace(classBody, open + 1);
427
+ return close === -1 ? '' : classBody.slice(open + 1, close);
428
+ }
429
+
430
+ /**
431
+ * Find browser-only globals and HTMLElement `this.<member>` accesses in a
432
+ * (redacted) method body. Returns one entry per distinct member.
433
+ * @param {string} code
434
+ * @returns {{ member: string, kind: string }[]}
435
+ */
436
+ function findBrowserMemberUses(code) {
437
+ // The class body arrives template-redacted, but `redactStringsAndTemplates`
438
+ // keeps single/double-quoted string CONTENT (real specifiers ride strings).
439
+ // Blank that too so a browser word inside a string literal (e.g. a label
440
+ // `'open the document'`) is not mistaken for a real global access.
441
+ code = code
442
+ .replace(/'(?:[^'\\]|\\.)*'/g, (s) => `'${' '.repeat(Math.max(0, s.length - 2))}'`)
443
+ .replace(/"(?:[^"\\]|\\.)*"/g, (s) => `"${' '.repeat(Math.max(0, s.length - 2))}"`);
444
+ const out = [];
445
+ const seen = new Set();
446
+ const gRe = new RegExp(`(?<![.\\w$])(${BROWSER_GLOBALS.join('|')})\\b`, 'g');
447
+ let m;
448
+ while ((m = gRe.exec(code)) !== null) {
449
+ if (seen.has(m[1])) continue;
450
+ seen.add(m[1]);
451
+ out.push({ member: m[1], kind: 'a browser global' });
452
+ }
453
+ const hRe = new RegExp(`\\bthis\\.(${HTMLELEMENT_MEMBERS.join('|')})\\b`, 'g');
454
+ while ((m = hRe.exec(code)) !== null) {
455
+ const key = `this.${m[1]}`;
456
+ if (seen.has(key)) continue;
457
+ seen.add(key);
458
+ out.push({ member: key, kind: 'an HTMLElement member' });
459
+ }
460
+ return out;
461
+ }
462
+
392
463
  /**
393
464
  * Scan a webjs app directory and report convention violations.
394
465
  *
@@ -557,6 +628,31 @@ export async function checkConventions(appDir, opts) {
557
628
  }
558
629
  }
559
630
 
631
+ // --- Rule: no-browser-globals-in-render ---
632
+ // The SSR pipeline runs the constructor (`new Cls()`) and calls `render()`
633
+ // on a bare server-side class with no DOM. A browser global or an
634
+ // HTMLElement member on `this` touched there throws at SSR time. Those
635
+ // belong in connectedCallback / lifecycle hooks, which SSR never calls.
636
+ if (isRuleEnabled('no-browser-globals-in-render', overrides)) {
637
+ for (const { rel, scan } of files) {
638
+ if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
639
+ for (const body of extractWebComponentClassBodies(scan)) {
640
+ for (const method of ['constructor', 'render']) {
641
+ const code = methodBodyOf(body, method);
642
+ if (!code) continue;
643
+ for (const { member, kind } of findBrowserMemberUses(code)) {
644
+ violations.push({
645
+ rule: 'no-browser-globals-in-render',
646
+ file: rel,
647
+ message: `\`${member}\` (${kind}) is used in ${method}(), which runs during SSR where it is not available, so it throws and the component fails to server-render.`,
648
+ fix: `Move browser-only work to connectedCallback() or a lifecycle hook (firstUpdated/updated), which SSR never calls. Seed first-paint defaults in the constructor only from server-known inputs (attributes / props), then refine in connectedCallback by writing to a signal.`,
649
+ });
650
+ }
651
+ }
652
+ }
653
+ }
654
+ }
655
+
560
656
  // --- Rule: no-server-env-in-components ---
561
657
  // Catches `process.env.X` reads in component files where X is not a
562
658
  // WEBJS_PUBLIC_* var and not NODE_ENV. The SSR shim only exposes those
@@ -41,6 +41,7 @@ import {
41
41
  extractWebComponentClassBodies,
42
42
  matchClosingBrace,
43
43
  redactStringsAndTemplates,
44
+ maskComments,
44
45
  } from './js-scan.js';
45
46
  import { transitiveDeps } from './module-graph.js';
46
47
 
@@ -553,9 +554,13 @@ function topLevelPropertyValues(obj) {
553
554
  export function extractRenderedTags(src) {
554
555
  /** @type {Set<string>} */
555
556
  const tags = new Set();
557
+ // Mask comments first so a `<some-tag>` written in a doc comment is not read
558
+ // as a rendered tag (#179). String and template content is kept, so real tags
559
+ // inside `html` templates are still found.
560
+ const masked = maskComments(src);
556
561
  const re = /<([a-z][a-z0-9]*-[a-z0-9-]*)\b/g;
557
562
  let m;
558
- while ((m = re.exec(src)) !== null) tags.add(m[1]);
563
+ while ((m = re.exec(masked)) !== null) tags.add(m[1]);
559
564
  return tags;
560
565
  }
561
566
 
@@ -656,15 +661,24 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
656
661
  continue;
657
662
  }
658
663
  if (typeof src !== 'string') continue;
659
- fileTags.set(file, extractRenderedTags(src));
660
- if (importsReactivePrimitive(src)) reactiveFiles.add(file);
661
- if (importsClientRouter(src)) clientRouterFiles.add(file);
662
- if (EVENT_BINDING_RE.test(src) || EVENT_PROP_RE.test(src) ||
663
- importsSideEffectNonCorePackage(src) || CLIENT_GLOBAL_RE.test(src) ||
664
- hasModuleScopeSideEffect(src)) {
664
+ // Mask comments once for every signal scan below (#179): a `<tag>`, an
665
+ // `@event`, a browser global, an `import`, or a `whenDefined` written in a
666
+ // comment must not register as a real signal. String and template content
667
+ // is kept, so a real rendered tag, a real `@click=${}` in an html template,
668
+ // and a real `whenDefined('tag')` (the tag rides a string) still match.
669
+ // (`importsSideEffectNonCorePackage` / `hasModuleScopeSideEffect` /
670
+ // `analyzeComponentSource` also redact strings/templates internally; running
671
+ // them on the comment-masked source just additionally drops comment prose.)
672
+ const masked = maskComments(src);
673
+ fileTags.set(file, extractRenderedTags(masked));
674
+ if (importsReactivePrimitive(masked)) reactiveFiles.add(file);
675
+ if (importsClientRouter(masked)) clientRouterFiles.add(file);
676
+ if (EVENT_BINDING_RE.test(masked) || EVENT_PROP_RE.test(masked) ||
677
+ importsSideEffectNonCorePackage(masked) || CLIENT_GLOBAL_RE.test(masked) ||
678
+ hasModuleScopeSideEffect(masked)) {
665
679
  clientGlobalOrBareFiles.add(file);
666
680
  }
667
- if (componentFiles.has(file) && analyzeComponentSource(src).interactive) {
681
+ if (componentFiles.has(file) && analyzeComponentSource(masked).interactive) {
668
682
  mustShip.add(file);
669
683
  }
670
684
  // Cross-module registration observation (#169): if THIS module observes
@@ -673,13 +687,13 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
673
687
  // file. Resolution against tagToFile / classToFile happens after the loop
674
688
  // (all components are known up front, but we collect here while we hold
675
689
  // each source). Verdict-safe: only ever forces MORE components to ship.
676
- for (const m of src.matchAll(WHEN_DEFINED_RE)) {
690
+ for (const m of masked.matchAll(WHEN_DEFINED_RE)) {
677
691
  const f = tagToFile.get(m[1]); if (f) observedComponentFiles.add(f);
678
692
  }
679
- for (const m of src.matchAll(TAG_DEFINED_RE)) {
693
+ for (const m of masked.matchAll(TAG_DEFINED_RE)) {
680
694
  const f = tagToFile.get(m[1]); if (f) observedComponentFiles.add(f);
681
695
  }
682
- for (const m of src.matchAll(INSTANCEOF_RE)) {
696
+ for (const m of masked.matchAll(INSTANCEOF_RE)) {
683
697
  const f = classToFile.get(m[1]); if (f) observedComponentFiles.add(f);
684
698
  }
685
699
  }
package/src/dev.js CHANGED
@@ -105,10 +105,13 @@ const MIME = {
105
105
  * lint rules catch these at edit time. webjs is buildless end-to-end:
106
106
  * there is no bundler fallback.
107
107
  *
108
- * @type {Map<string, { mtimeMs: number, code: string, map: string | null }>}
108
+ * The transformed bytes are cached per request handler in `state.tsCache`
109
+ * (a `Map<string, { mtimeMs, code, map }>`), bounded to `TS_CACHE_MAX`
110
+ * entries. The cache is per-handler rather than module-global because the
111
+ * cached code bakes in that handler's elision verdict, so two handlers for
112
+ * the same app with different elision settings must not share it.
109
113
  */
110
114
  const TS_CACHE_MAX = 500;
111
- const TS_CACHE = new Map();
112
115
 
113
116
  /**
114
117
  * Auto-load `<appDir>/.env` into `process.env` once at boot. Mirrors
@@ -148,16 +151,40 @@ function loadAppEnv(appDir) {
148
151
  }
149
152
 
150
153
  /**
151
- * Read the project-level elision switch from `package.json`.
152
- * `{ "webjs": { "elide": false } }` disables display-only and inert-route
153
- * elision app-wide (everything ships, like before the feature existed).
154
- * Any other value, or an absent key, leaves elision enabled (the default).
155
- * Re-read on every rebuild so toggling the switch takes effect without a
156
- * server restart.
154
+ * Read the `WEBJS_ELIDE` environment override, if set.
155
+ * `0` / `false` / `off` / `no` (case-insensitive) force elision OFF;
156
+ * `1` / `true` / `on` / `yes` force it ON. Any other value, or an unset
157
+ * variable, returns `undefined` so the caller falls through to the
158
+ * `package.json` switch. The env override is the deploy-time / ops escape
159
+ * hatch: force-disable elision to rule it out while debugging a wrong-strip
160
+ * without editing committed code, or force-enable it regardless of an
161
+ * app's `package.json`. It is also the seam the differential elision test
162
+ * uses to render the same app on and off in one process.
163
+ * @returns {boolean | undefined}
164
+ */
165
+ function elideEnvOverride() {
166
+ const raw = process.env.WEBJS_ELIDE;
167
+ if (raw == null || raw === '') return undefined;
168
+ const v = String(raw).trim().toLowerCase();
169
+ if (v === '0' || v === 'false' || v === 'off' || v === 'no') return false;
170
+ if (v === '1' || v === 'true' || v === 'on' || v === 'yes') return true;
171
+ return undefined;
172
+ }
173
+
174
+ /**
175
+ * Read the project-level elision switch.
176
+ * Precedence: the `WEBJS_ELIDE` env override wins when set, otherwise the
177
+ * `package.json` `{ "webjs": { "elide": false } }` switch disables
178
+ * display-only and inert-route elision app-wide (everything ships, like
179
+ * before the feature existed). Any other value, or an absent key, leaves
180
+ * elision enabled (the default). Re-read on every rebuild so toggling
181
+ * either control takes effect without a server restart.
157
182
  * @param {string} appDir
158
183
  * @returns {Promise<boolean>}
159
184
  */
160
- async function readElideEnabled(appDir) {
185
+ export async function readElideEnabled(appDir) {
186
+ const override = elideEnvOverride();
187
+ if (override !== undefined) return override;
161
188
  try {
162
189
  const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
163
190
  if (pkg && pkg.webjs && pkg.webjs.elide === false) return false;
@@ -267,6 +294,12 @@ export async function createRequestHandler(opts) {
267
294
  elidableComponents: new Set(),
268
295
  inertRouteModules: new Set(),
269
296
  browserBoundFiles: null,
297
+ // Transformed-source cache (stripped TS + applied elision). Per-handler,
298
+ // NOT module-global: the cached bytes bake in THIS handler's elision
299
+ // verdict, so two handlers for the same app with different elision
300
+ // settings (a multi-tenant embedder, or the differential elision test)
301
+ // must not share it, or the second would serve the first's elided source.
302
+ tsCache: new Map(),
270
303
  };
271
304
 
272
305
  // All whole-app analysis is built lazily on the first request, memoized so
@@ -500,12 +533,12 @@ export async function createRequestHandler(opts) {
500
533
  // it so routing reflects added/removed route files immediately.
501
534
  state.routeTable = await buildRouteTable(appDir);
502
535
  clearVendorCache();
503
- TS_CACHE.clear();
536
+ state.tsCache.clear();
504
537
  // Invalidate the lazy analysis; the next request rebuilds the graph,
505
538
  // component scan, gate, action index, middleware, elision, and vendor map.
506
539
  // Wait out any in-flight build first so it cannot commit stale results
507
540
  // after the reset. A dependency edit can flip an elision verdict without
508
- // changing an importer's mtime, hence the TS_CACHE.clear above.
541
+ // changing an importer's mtime, hence the state.tsCache.clear above.
509
542
  if (readyInFlight) { try { await readyInFlight; } catch {} }
510
543
  // Bump the vendor generation so a vendor resolve still in flight from the
511
544
  // previous build cannot flip vendorResolved against the fresh state.
@@ -1028,7 +1061,7 @@ async function handleCore(req, ctx) {
1028
1061
  appDir,
1029
1062
  };
1030
1063
  if (/\.m?ts$/.test(abs)) {
1031
- return tsResponse(abs, dev, elideOpts);
1064
+ return tsResponse(abs, dev, elideOpts, state.tsCache);
1032
1065
  }
1033
1066
  if (/\.m?js$/.test(abs)) {
1034
1067
  return jsModuleResponse(abs, dev, elideOpts);
@@ -1412,17 +1445,21 @@ async function stripTs(source, _abs) {
1412
1445
 
1413
1446
  /**
1414
1447
  * Serve a `.ts` / `.mts` source file as JavaScript via {@link stripTs}.
1415
- * Result is cached by mtime so subsequent requests are instant; a
1416
- * file edit invalidates naturally. `elideOpts` additionally strips
1417
- * side-effect imports of display-only components from the served code.
1448
+ * Result is cached by mtime in the handler's own `cache` so subsequent
1449
+ * requests are instant; a file edit invalidates naturally. `elideOpts`
1450
+ * additionally strips side-effect imports of display-only components from
1451
+ * the served code, which is exactly why `cache` is the per-handler
1452
+ * `state.tsCache` and not a module-global: the cached bytes bake in this
1453
+ * handler's elision verdict.
1418
1454
  *
1419
1455
  * @param {string} abs
1420
1456
  * @param {boolean} dev
1421
1457
  * @param {{ moduleGraph: any, elidableComponents: Set<string>|undefined, appDir: string }} [elideOpts]
1458
+ * @param {Map<string, { mtimeMs: number, code: string, map: string | null }>} cache the handler's `state.tsCache`
1422
1459
  */
1423
- async function tsResponse(abs, dev, elideOpts) {
1460
+ async function tsResponse(abs, dev, elideOpts, cache) {
1424
1461
  const st = await stat(abs);
1425
- const cached = TS_CACHE.get(abs);
1462
+ const cached = cache.get(abs);
1426
1463
  if (cached && cached.mtimeMs === st.mtimeMs) {
1427
1464
  return new Response(cached.code, {
1428
1465
  headers: {
@@ -1475,11 +1512,11 @@ async function tsResponse(abs, dev, elideOpts) {
1475
1512
  );
1476
1513
  }
1477
1514
  // Evict oldest entry if cache is full (simple FIFO: Map preserves insertion order).
1478
- if (TS_CACHE.size >= TS_CACHE_MAX) {
1479
- const oldest = TS_CACHE.keys().next().value;
1480
- TS_CACHE.delete(oldest);
1515
+ if (cache.size >= TS_CACHE_MAX) {
1516
+ const oldest = cache.keys().next().value;
1517
+ cache.delete(oldest);
1481
1518
  }
1482
- TS_CACHE.set(abs, { mtimeMs: st.mtimeMs, code, map: null });
1519
+ cache.set(abs, { mtimeMs: st.mtimeMs, code, map: null });
1483
1520
  return new Response(code, {
1484
1521
  headers: {
1485
1522
  'content-type': 'application/javascript; charset=utf-8',
package/src/js-scan.js CHANGED
@@ -221,6 +221,111 @@ export function redactStringsAndTemplates(src) {
221
221
  return out;
222
222
  }
223
223
 
224
+ /**
225
+ * Blank ONLY comments, keeping string AND template-literal content verbatim
226
+ * (position-preserving: same length, newlines kept). The sibling
227
+ * `redactStringsAndTemplates` blanks templates too, which is wrong for callers
228
+ * that need to read inside `html` templates (the elision render-tag scanner) or
229
+ * inside string arguments (`whenDefined('tag')`). This keeps both and removes
230
+ * only comment text, so prose in a comment cannot be read as a real signal
231
+ * (issue #179). It reuses the same regex-versus-division and tagged-template
232
+ * disambiguation so a `//` inside a string/template/regex is never mistaken for
233
+ * a comment.
234
+ *
235
+ * @param {string} src
236
+ * @returns {string} src with comment bodies blanked, everything else verbatim
237
+ */
238
+ export function maskComments(src) {
239
+ const n = src.length;
240
+ let out = '';
241
+ let i = 0;
242
+ let lastSig = '';
243
+ let lastWord = '';
244
+ let lastWordIsProp = false;
245
+ let lastWasIncDec = false;
246
+ const markValue = () => { lastSig = 'x'; lastWord = ''; lastWordIsProp = false; lastWasIncDec = false; };
247
+ const isRegex = () => {
248
+ if (lastSig === '') return true;
249
+ if (lastSig === ')' || lastSig === ']') return false;
250
+ if (lastSig === "'" || lastSig === '"' || lastSig === '`') return false;
251
+ if (lastWasIncDec) return false;
252
+ if (/[\w$]/.test(lastSig)) return !lastWordIsProp && REGEX_PRECEDING_KEYWORDS.has(lastWord);
253
+ return true;
254
+ };
255
+ // Comments: blank the body (keep the `//` / `/* */` delimiters and newlines).
256
+ const scanLineComment = () => { out += '//'; i += 2; while (i < n && src[i] !== '\n') { out += ' '; i++; } };
257
+ const scanBlockComment = () => {
258
+ out += '/*'; i += 2;
259
+ while (i < n) {
260
+ if (src[i] === '*' && src[i + 1] === '/') { out += '*/'; i += 2; return; }
261
+ out += src[i] === '\n' ? '\n' : ' '; i++;
262
+ }
263
+ };
264
+ // String / template / regex: copy verbatim, but lex correctly so a `//` or
265
+ // `/*` inside them is not treated as a comment.
266
+ const scanString = (q) => {
267
+ out += q; i++;
268
+ while (i < n) {
269
+ if (src[i] === '\\' && i + 1 < n) { out += src[i] + src[i + 1]; i += 2; continue; }
270
+ if (src[i] === q) { out += q; i++; break; }
271
+ if (src[i] === '\n') { out += '\n'; i++; break; }
272
+ out += src[i]; i++;
273
+ }
274
+ markValue();
275
+ };
276
+ const scanRegex = () => {
277
+ out += '/'; i++;
278
+ let inClass = false;
279
+ while (i < n) {
280
+ const d = src[i];
281
+ if (d === '\\' && i + 1 < n) { out += d + src[i + 1]; i += 2; continue; }
282
+ if (d === '\n') break;
283
+ if (d === '[') inClass = true;
284
+ else if (d === ']') inClass = false;
285
+ else if (d === '/' && !inClass) { out += '/'; i++; break; }
286
+ out += d; i++;
287
+ }
288
+ markValue();
289
+ };
290
+ const scanTemplate = () => {
291
+ out += '`'; i++;
292
+ while (i < n) {
293
+ const c = src[i];
294
+ if (c === '\\' && i + 1 < n) { out += c + src[i + 1]; i += 2; continue; }
295
+ if (c === '`') { out += '`'; i++; break; }
296
+ if (c === '$' && src[i + 1] === '{') { out += '${'; i += 2; scanCode(true); if (i < n && src[i] === '}') { out += '}'; i++; } continue; }
297
+ out += c; i++;
298
+ }
299
+ markValue();
300
+ };
301
+ function scanCode(stopHole) {
302
+ let brace = 0;
303
+ while (i < n) {
304
+ const c = src[i], next = src[i + 1];
305
+ if (stopHole && c === '}' && brace === 0) return;
306
+ if (c === '/' && next === '/') { scanLineComment(); continue; }
307
+ if (c === '/' && next === '*') { scanBlockComment(); continue; }
308
+ if (c === '/' && isRegex()) { scanRegex(); continue; }
309
+ if (c === "'" || c === '"') { scanString(c); continue; }
310
+ if (c === '`') { scanTemplate(); continue; }
311
+ if (c === '{') { brace++; lastSig = '{'; lastWord = ''; lastWasIncDec = false; out += c; i++; continue; }
312
+ if (c === '}') { brace--; lastSig = '}'; lastWord = ''; lastWasIncDec = false; out += c; i++; continue; }
313
+ if (/[A-Za-z_$]/.test(c)) {
314
+ const prop = lastSig === '.';
315
+ let w = '';
316
+ while (i < n && /[\w$]/.test(src[i])) { w += src[i]; out += src[i]; i++; }
317
+ lastWord = w; lastSig = w[w.length - 1]; lastWordIsProp = prop; lastWasIncDec = false;
318
+ continue;
319
+ }
320
+ if (/\s/.test(c)) { out += c; i++; continue; }
321
+ lastWasIncDec = (c === '+' || c === '-') && c === lastSig;
322
+ lastSig = c; lastWord = ''; out += c; i++;
323
+ }
324
+ }
325
+ scanCode(false);
326
+ return out;
327
+ }
328
+
224
329
  /**
225
330
  * Extract the body of every `class … extends WebComponent { … }` block.
226
331
  * Brace-counts to handle nested template literals, methods, and arrow