@webjsdev/server 0.7.3 → 0.8.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.
package/src/importmap.js CHANGED
@@ -1,40 +1,303 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { digestHex } from './crypto-utils.js';
4
+ import { jsonForScriptTag } from './script-tag-json.js';
5
+
6
+ // Local attribute escaper. Matches ssr.js's escapeAttr (the source
7
+ // of truth for HTML attribute escaping in this package). Kept inline
8
+ // to avoid a cross-file dependency for one small helper.
9
+ function escapeAttr(s) {
10
+ return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
11
+ }
12
+
1
13
  /**
2
14
  * Build the import map JSON injected into every SSR HTML document.
3
15
  *
4
16
  * Additional vendor entries are added automatically when the bare-import
5
- * scanner discovers npm packages used by client code (Vite-style
6
- * optimizeDeps).
17
+ * scanner discovers npm packages used by client code. The resolution
18
+ * happens via `vendor.js`'s `resolveVendorImports`, which reads the
19
+ * committed `.webjs/vendor/importmap.json` if present, else calls
20
+ * `api.jspm.io/generate` once on the first request (memoized), never at
21
+ * boot. Browser fetches vendor packages
22
+ * directly from jspm.io's CDN (default) or from local `/__webjs/vendor/`
23
+ * paths (after `webjs vendor pin --download`).
7
24
  */
8
25
 
9
26
  /** @type {Record<string, string>} */
10
27
  let _extraEntries = {};
11
28
 
12
29
  /**
13
- * Merge additional vendor entries into the import map.
14
- * Called by the server after scanning for bare imports.
30
+ * SRI integrity hashes keyed by FINAL URL (post-importmap-rewrite).
31
+ * Populated only when a pin file with `integrity` is present;
32
+ * live-API mode skips it.
33
+ * @type {Record<string, string>}
34
+ */
35
+ let _vendorIntegrity = {};
36
+
37
+ /**
38
+ * Merge additional vendor entries into the import map and precompute
39
+ * the importmap-hash so `importMapHash()` can stay synchronous on the
40
+ * per-request SSR hot path. Called from `ensureReady()` on the first
41
+ * request and on every vendor rebuild.
42
+ *
15
43
  * @param {Record<string, string>} entries
44
+ * @param {Record<string, string>} [integrity] SRI hashes keyed by URL
45
+ * @returns {Promise<void>}
16
46
  */
17
- export function setVendorEntries(entries) {
47
+ export async function setVendorEntries(entries, integrity) {
18
48
  _extraEntries = entries;
49
+ _vendorIntegrity = integrity || {};
50
+ _importMapHash = await digestHex('SHA-256', JSON.stringify(buildImportMap()));
51
+ }
52
+
53
+ /**
54
+ * Stable SHA-256 of the current importmap JSON, used as the
55
+ * `data-webjs-build` attribute on `<script type="importmap">` and
56
+ * as the `X-Webjs-Build` response header on every SSR response.
57
+ *
58
+ * Purpose: the X-Webjs-Have partial-response optimization in ssr.js
59
+ * short-circuits at the outermost cached layout and returns only the
60
+ * inner body (no head, no importmap). Without the build header the
61
+ * client router has no way to detect a deploy that bumped the
62
+ * importmap. After a `webjs vendor pin` rerun the user's next
63
+ * intra-shell nav would stay on the stale importmap and the new
64
+ * vendor URLs would never load. The header lets applySwap detect
65
+ * the change and hard-reload before applying the swap.
66
+ *
67
+ * Synchronous accessor. The hash is precomputed eagerly inside
68
+ * `setVendorEntries` (which `ensureReady()` `await`s on the first request
69
+ * and on every rebuild) so the per-request SSR hot path can return the
70
+ * cached string without crossing a Promise boundary.
71
+ *
72
+ * Returns an empty string if `setVendorEntries` has never run; the
73
+ * client router treats an empty `X-Webjs-Build` as "version unknown"
74
+ * and skips the importmap drift check, which is the right behaviour
75
+ * for tests / embedding contexts that never set vendor entries.
76
+ *
77
+ * @returns {string} e.g. `abc123…` (hex, 64 chars)
78
+ */
79
+ let _importMapHash = '';
80
+ export function importMapHash() {
81
+ return _importMapHash;
82
+ }
83
+
84
+ /**
85
+ * Look up the SRI integrity hash for a vendor URL, or empty string if
86
+ * none. Used by ssr.js to add `integrity="..."` to modulepreload tags
87
+ * pointing at vendor URLs.
88
+ *
89
+ * @param {string} url
90
+ * @returns {string}
91
+ */
92
+ export function vendorIntegrityFor(url) {
93
+ return _vendorIntegrity[url] || '';
94
+ }
95
+
96
+ /**
97
+ * The `@webjsdev/core` install's importmap entries, derived from its
98
+ * own `package.json` `exports` field. Populated by `setCoreInstall`
99
+ * at boot.
100
+ *
101
+ * Initialized to the two minimum-safe defaults (the bare specifier
102
+ * pointing at the browser source-mode entry and the catch-all prefix
103
+ * pointing at `src/`) so any consumer that calls `buildImportMap()`
104
+ * before `setCoreInstall` runs still gets a usable map. Pre-#118 the
105
+ * legacy `coreMappings` were always derived from a boolean and so
106
+ * could not be empty; this keeps that fail-open posture for embedded
107
+ * SSR test helpers and one-shot tooling that imports `importmap.js`
108
+ * without booting `dev.js`.
109
+ *
110
+ * @type {Record<string, string>}
111
+ */
112
+ let _coreEntries = {
113
+ '@webjsdev/core': '/__webjs/core/index-browser.js',
114
+ '@webjsdev/core/': '/__webjs/core/src/',
115
+ };
116
+
117
+ /**
118
+ * Bind the importmap to a specific `@webjsdev/core` install. The
119
+ * builder reads the package's `package.json` exports field once and
120
+ * derives one importmap entry per exported subpath, picking the
121
+ * `default` (bundled `dist/`) condition when `distMode` is true and
122
+ * the `source` (per-file `src/`) condition when it's false. The
123
+ * derivation lets the framework drop the 9-line hardcoded mapping
124
+ * table that used to live here, and means the importmap follows the
125
+ * shipped package whenever subpaths are renamed or added.
126
+ *
127
+ * The bare `@webjsdev/core` specifier still hardcodes its target
128
+ * (the browser-only entry shipped at `index-browser.js` /
129
+ * `dist/webjs-core-browser.js`) because that file is not declared
130
+ * in the exports field, by design: it is a server-stripped surface
131
+ * meant for the importmap-driven browser route, not Node resolution.
132
+ *
133
+ * Called once by `dev.js` at boot. Not re-called on file-watcher
134
+ * rebuilds today; if `@webjsdev/core/package.json` is edited in a
135
+ * long-running dev session (e.g. workspace dev that runs a fresh
136
+ * `npm run build:dist`), the derivation is refreshed on next server
137
+ * restart, not on the watcher tick. Pre-#118 the legacy
138
+ * `setCoreDistMode` had the same behaviour: only the dist-presence
139
+ * boolean was watched, not the package.json itself.
140
+ *
141
+ * Like `setVendorEntries`, the importmap-hash is recomputed eagerly
142
+ * so `importMapHash()` stays synchronous on the per-request SSR
143
+ * hot path.
144
+ *
145
+ * @param {string} coreDir absolute path to the resolved `@webjsdev/core` install
146
+ * @param {boolean} distMode true when both `webjs-core.js` and `webjs-core-browser.js` exist in `dist/`
147
+ * @returns {Promise<void>}
148
+ */
149
+ export async function setCoreInstall(coreDir, distMode) {
150
+ _coreEntries = buildCoreEntries(coreDir, !!distMode);
151
+ _importMapHash = await digestHex('SHA-256', JSON.stringify(buildImportMap()));
152
+ }
153
+
154
+ /**
155
+ * Read `<coreDir>/package.json` and derive importmap entries from
156
+ * its `exports` field. The function is pure (no side effects) and
157
+ * exported so tests can exercise the derivation directly.
158
+ *
159
+ * For each subpath in `exports` whose value is an object form, emit
160
+ * one entry. Pick the `default` value in dist mode (a bundled
161
+ * `dist/webjs-core-*.js`) and the `source` value in src mode (a
162
+ * per-file `src/*.js`). When `source` is absent (e.g. `./component`,
163
+ * whose shape is `{ types, default }` and whose `default` is itself
164
+ * a `src/` path), fall back to `default` in src mode so the import
165
+ * still resolves with a `.js` extension on the URL.
166
+ *
167
+ * Subpaths with a plain string value (`./client`, `./server`,
168
+ * `./registry`, `./signals`, `./package.json`) are not mapped
169
+ * explicitly; the catch-all `@webjsdev/core/` prefix routes them
170
+ * through `/__webjs/core/src/`. Future-added subpaths added in
171
+ * string form land on the catch-all the same way.
172
+ *
173
+ * Path-traversal guard: any `default` / `source` value that contains
174
+ * `..` is skipped. The trust boundary today is the framework's own
175
+ * `@webjsdev/core/package.json`, but the guard makes a future shift
176
+ * to user-controlled `coreDir` (e.g. via a `--core-dir` flag) safe
177
+ * by construction.
178
+ *
179
+ * @param {string} coreDir
180
+ * @param {boolean} distMode
181
+ * @returns {Record<string, string>}
182
+ */
183
+ export function buildCoreEntries(coreDir, distMode) {
184
+ /** @type {Record<string, string>} */
185
+ const out = {
186
+ // Bare specifier: browser-only entry, slim by design (drops
187
+ // render-server, expose, setCspNonceProvider). Node-side
188
+ // consumers resolve via the package.json exports `default`
189
+ // condition and land on `index.js` instead.
190
+ '@webjsdev/core': distMode
191
+ ? '/__webjs/core/dist/webjs-core-browser.js'
192
+ : '/__webjs/core/index-browser.js',
193
+ // Catch-all: source-only subpaths (`./client`, `./server`,
194
+ // `./component`, `./registry`, `./signals`) and any future
195
+ // subpath not yet enumerated by exports still resolve.
196
+ '@webjsdev/core/': '/__webjs/core/src/',
197
+ };
198
+ let pkg;
199
+ try {
200
+ pkg = JSON.parse(readFileSync(join(coreDir, 'package.json'), 'utf8'));
201
+ } catch {
202
+ // Without a readable package.json the bare + catch-all entries
203
+ // above are the minimum useful map. Per-subpath entries stay
204
+ // missing; the catch-all picks them up at src/<name> (callers
205
+ // would need to include the .js extension in their import).
206
+ return out;
207
+ }
208
+ const exportsField = pkg && pkg.exports;
209
+ if (!exportsField || typeof exportsField !== 'object') return out;
210
+ for (const [subpath, entry] of Object.entries(exportsField)) {
211
+ // Skip the bare `.` (handled above) and any non-subpath key.
212
+ if (subpath === '.' || !subpath.startsWith('./') || subpath.endsWith('/')) continue;
213
+ // Only object-form entries with both default and source carry
214
+ // a dist mapping. String-form entries (`./client`,
215
+ // `./component` once it loses its types, …) stay on the
216
+ // catch-all.
217
+ if (!entry || typeof entry !== 'object') continue;
218
+ // Pick the condition for the requested mode. In src mode,
219
+ // entries that lack a `source` (e.g. `./component`, whose
220
+ // package.json shape is `{ types, default }`) fall back to
221
+ // `entry.default`. The fallback URL is still a `src/` path on
222
+ // those entries, so it resolves correctly without forcing users
223
+ // to add a `.js` extension to subpath imports.
224
+ let targetRel = distMode ? entry.default : entry.source;
225
+ if (typeof targetRel !== 'string') targetRel = entry.default;
226
+ if (typeof targetRel !== 'string' || !targetRel.startsWith('./')) continue;
227
+ // Reject paths containing `..` to guard against a malformed or
228
+ // adversarial `exports` field producing a path-traversal URL.
229
+ // The check is deliberately broad: `..` substring catches both
230
+ // `../etc/passwd` and `./foo/../bar`.
231
+ if (targetRel.includes('..')) continue;
232
+ // `./directives` → `@webjsdev/core/directives`,
233
+ // `./dist/webjs-core-directives.js` → `/__webjs/core/dist/webjs-core-directives.js`.
234
+ out['@webjsdev/core' + subpath.slice(1)] = '/__webjs/core/' + targetRel.slice(2);
235
+ }
236
+ return out;
19
237
  }
20
238
 
21
239
  export function buildImportMap() {
22
- return {
23
- imports: {
24
- '@webjsdev/core': '/__webjs/core/index.js',
25
- '@webjsdev/core/': '/__webjs/core/src/',
26
- '@webjsdev/core/client-router': '/__webjs/core/src/router-client.js',
27
- '@webjsdev/core/lazy-loader': '/__webjs/core/src/lazy-loader.js',
28
- '@webjsdev/core/directives': '/__webjs/core/src/directives.js',
29
- '@webjsdev/core/context': '/__webjs/core/src/context.js',
30
- '@webjsdev/core/testing': '/__webjs/core/src/testing.js',
31
- '@webjsdev/core/task': '/__webjs/core/src/task.js',
32
- ..._extraEntries,
33
- },
240
+ const merged = {
241
+ ..._coreEntries,
242
+ ..._extraEntries,
34
243
  };
244
+ // Sort keys so logically-identical importmaps serialize byte-for-byte
245
+ // identically. The client router compares textContent to detect
246
+ // post-deploy importmap mismatches; without a stable order the
247
+ // scanner's filesystem-iteration order could change between deploys
248
+ // (e.g. after a file rename) and trigger a spurious hard reload
249
+ // even though the content didn't actually change.
250
+ /** @type {Record<string, string>} */
251
+ const imports = {};
252
+ for (const k of Object.keys(merged).sort()) imports[k] = merged[k];
253
+
254
+ // Emit `integrity` per the importmap-integrity spec (Chrome 132+,
255
+ // Safari 18.4+, Firefox flagged). Browsers without support ignore
256
+ // the field; per-tag SRI on modulepreload covers them.
257
+ //
258
+ // Filter orphan integrity entries (URLs that aren't actually in
259
+ // imports). The browser only consults integrity for URLs that
260
+ // resolve through the importmap, so orphans are harmless but bloat
261
+ // the JSON, defeat the importMapHash stability invariant on
262
+ // unrelated pin file edits, and leak removed URLs to the wire.
263
+ const out = { imports };
264
+ const usedUrls = new Set(Object.values(imports));
265
+ const intKeys = Object.keys(_vendorIntegrity).filter(k => usedUrls.has(k)).sort();
266
+ if (intKeys.length) {
267
+ /** @type {Record<string, string>} */
268
+ const integrity = {};
269
+ for (const k of intKeys) integrity[k] = _vendorIntegrity[k];
270
+ out.integrity = integrity;
271
+ }
272
+ return out;
35
273
  }
36
274
 
37
- /** Serialise the import map to an HTML script tag string. */
38
- export function importMapTag() {
39
- return `<script type="importmap">${JSON.stringify(buildImportMap())}</script>`;
275
+ /**
276
+ * Serialise the import map to an HTML script tag string.
277
+ *
278
+ * When `nonce` is provided (extracted from the incoming
279
+ * Content-Security-Policy header by ssr.js), it's emitted as
280
+ * `nonce="..."` on the script tag. Strict-CSP apps using
281
+ * `script-src 'nonce-...'` require this; without it the browser
282
+ * blocks the importmap and every bare-specifier import fails.
283
+ *
284
+ * Defense-in-depth: JSON content is run through `jsonForScriptTag`
285
+ * so a string value containing `</script>` (e.g. a maliciously
286
+ * crafted vendor URL that somehow slipped past the jspm.io filter)
287
+ * cannot close the importmap tag early and inject script content.
288
+ *
289
+ * @param {{ nonce?: string }} [opts]
290
+ */
291
+ export function importMapTag(opts = {}) {
292
+ // Full attribute escape, not just `"` to `&quot;`. The nonce arrives
293
+ // from the request's CSP header (parsed by ssr.js), which we treat
294
+ // as untrusted input even though CSP spec restricts nonce charset to
295
+ // base64-ish. A misconfigured upstream emitting `nonce-<bad>` should
296
+ // not get its `<` rendered raw into our HTML.
297
+ const n = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : '';
298
+ // Stamp the build hash so the client router can detect post-deploy
299
+ // importmap changes on intra-shell partial-response navigations.
300
+ // See importMapHash() above for the rationale.
301
+ const b = ` data-webjs-build="${importMapHash()}"`;
302
+ return `<script type="importmap"${n}${b}>${jsonForScriptTag(buildImportMap())}</script>`;
40
303
  }
package/src/js-scan.js ADDED
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Low-level lexical scanning helpers shared by the convention validator
3
+ * (`check.js`) and the component-elision analyser (`component-elision.js`).
4
+ *
5
+ * These are deliberately a hand-rolled lexer, NOT a full TS parse. The
6
+ * framework prioritises fast dev-time rebuilds; a real parser would be ~50x
7
+ * slower for patterns this shallow. The lexer tracks the JS lexical grammar
8
+ * (strings, regex literals, comments, and templates with nested `${...}`
9
+ * interpolation) so structural scanners never trip on a literal's contents.
10
+ */
11
+
12
+ /**
13
+ * Keywords after which a `/` opens a regex literal rather than dividing
14
+ * (`return /re/`, `typeof /re/`). After a plain identifier or number a `/` is
15
+ * division. Used by the lexer's regex-versus-division decision.
16
+ */
17
+ const REGEX_PRECEDING_KEYWORDS = new Set([
18
+ 'return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete', 'void',
19
+ 'do', 'else', 'case', 'yield', 'await', 'throw',
20
+ ]);
21
+
22
+ /**
23
+ * Return `src` with the BODY of every comment, single-quoted string,
24
+ * double-quoted string, and template literal replaced by spaces (with
25
+ * newlines preserved). Quote delimiters / comment markers themselves
26
+ * are kept so the brace counter and other structural scanners still
27
+ * see the surrounding shape. Positions (line + column) are preserved
28
+ * exactly, so a violation reported against the redacted source maps
29
+ * back to the same line/column in the original.
30
+ *
31
+ * The point: lint rules that pattern-match across raw source (regex
32
+ * for `class X extends WebComponent`, `enum`, `register('tag')`,
33
+ * etc.) must not match the same pattern when it appears as a
34
+ * code-example string INSIDE an `html\`...\`` template body. Docs
35
+ * pages legitimately render such examples to teach users; without
36
+ * redaction the scanner reads them as real declarations and emits
37
+ * false positives.
38
+ *
39
+ * Template literals split by tag + shape:
40
+ *
41
+ * Preserved verbatim only when ALL of: untagged, no newline in the
42
+ * body, no `${...}` interpolation. This is the "backticks as a
43
+ * quote-style alias" shape, e.g. `` register(`my-tag`) ``, where
44
+ * the backtick literal is morally a short string argument. Lint
45
+ * rules then read it the same way they read `register('my-tag')`.
46
+ *
47
+ * Blanked in every other case:
48
+ * (a) TAGGED templates like `` html`...` ``, `` css`...` ``,
49
+ * `` Class.method`...` ``, which carry multi-line code-shaped
50
+ * strings in docs pages and JSDoc examples.
51
+ * (b) Multi-line untagged literals, typically code-shaped
52
+ * fixtures the linter should not read in place.
53
+ * (c) Interpolated literals; the `${...}` body is dynamic and
54
+ * cannot be statically validated anyway.
55
+ *
56
+ * A real `register('foo')` call inside a blanked region (e.g.
57
+ * inside a tagged interpolation `` html`${X.register('foo')}` ``)
58
+ * disappears from the lint surface. Accepted trade-off: register()
59
+ * calls in practice live at top-level in component files, not
60
+ * inside template interpolations.
61
+ *
62
+ * Regex literals ARE tracked. A `/.../` in expression position (decided by
63
+ * the previous significant token, the standard regex-versus-division rule)
64
+ * has its body blanked with the `/` delimiters kept, so a quote, brace, or
65
+ * comment-like sequence inside a regex cannot desync the walker. Template
66
+ * literals are tracked with full `${...}` interpolation and arbitrary
67
+ * nesting, so a nested `` html`...${html`...`}...` `` is delimited correctly
68
+ * (the inner backtick is not mistaken for the outer close).
69
+ *
70
+ * @param {string} src
71
+ * @returns {string}
72
+ */
73
+ export function redactStringsAndTemplates(src) {
74
+ const n = src.length;
75
+ let out = '';
76
+ let i = 0;
77
+ // Previous significant token in code position, tracked as we walk (more
78
+ // robust than scanning `out`, whose tail is blanked spaces inside a hole).
79
+ // `lastSig` is the last non-whitespace source char; `lastWord` is the last
80
+ // identifier. Both drive regex-versus-division and tagged-template decisions.
81
+ let lastSig = '';
82
+ let lastWord = '';
83
+ // Whether `lastWord` was a property access (`.of`, `?.in`). A member named
84
+ // like a keyword is a value, never a regex-preceding keyword.
85
+ let lastWordIsProp = false;
86
+ // Whether the last two significant chars formed a postfix `++` / `--`. A
87
+ // postfix increment/decrement yields a value, so a following `/` is division
88
+ // (`a++ / 2`), not a regex start. Without this the `/` opens a phantom regex
89
+ // that blanks to the next `/`, swallowing a following module-scope call.
90
+ let lastWasIncDec = false;
91
+ // After a literal (string/regex/template) the next `/` is division and the
92
+ // next backtick is a tag, so mark a value-ender.
93
+ const markValue = () => { lastSig = 'x'; lastWord = ''; lastWordIsProp = false; lastWasIncDec = false; };
94
+
95
+ // `/` opens a regex unless the previous token is a value (identifier that is
96
+ // not a regex-preceding keyword, number, `)`, `]`, or a literal).
97
+ const isRegex = () => {
98
+ if (lastSig === '') return true;
99
+ if (lastSig === ')' || lastSig === ']') return false;
100
+ if (lastSig === "'" || lastSig === '"' || lastSig === '`') return false;
101
+ if (lastWasIncDec) return false; // postfix `a++` / `a--` is a value
102
+ if (/[\w$]/.test(lastSig)) return !lastWordIsProp && REGEX_PRECEDING_KEYWORDS.has(lastWord);
103
+ return true;
104
+ };
105
+ // A template is tagged when the previous token is a value.
106
+ const isTagged = () => /[\w$)\]'"`]/.test(lastSig);
107
+
108
+ const scanLineComment = () => {
109
+ out += '//'; i += 2;
110
+ while (i < n && src[i] !== '\n') { out += ' '; i++; }
111
+ };
112
+ const scanBlockComment = () => {
113
+ out += '/*'; i += 2;
114
+ while (i < n) {
115
+ if (src[i] === '*' && src[i + 1] === '/') { out += '*/'; i += 2; return; }
116
+ out += src[i] === '\n' ? '\n' : ' '; i++;
117
+ }
118
+ };
119
+ const scanRegex = () => {
120
+ out += '/'; i++;
121
+ let inClass = false;
122
+ while (i < n) {
123
+ const d = src[i];
124
+ if (d === '\\' && i + 1 < n) { out += ' '; i += 2; continue; }
125
+ if (d === '\n') break; // unterminated regex
126
+ if (d === '[') inClass = true;
127
+ else if (d === ']') inClass = false;
128
+ else if (d === '/' && !inClass) { out += '/'; i++; break; }
129
+ out += ' '; i++;
130
+ }
131
+ markValue();
132
+ };
133
+ // Strings: KEEP the body verbatim at top level (so tag-name-has-hyphen can
134
+ // read register('foo')); blank it when inside an already-blanked hole.
135
+ const scanString = (q, blank) => {
136
+ out += q; i++;
137
+ while (i < n) {
138
+ if (src[i] === '\\' && i + 1 < n) { out += blank ? ' ' : src[i] + src[i + 1]; i += 2; continue; }
139
+ if (src[i] === q) { out += q; i++; break; }
140
+ if (src[i] === '\n') { out += '\n'; i++; break; } // unterminated
141
+ out += blank ? ' ' : src[i]; i++;
142
+ }
143
+ markValue();
144
+ };
145
+ // Template literal. `forceBlank` is set when already inside a blanked hole
146
+ // (everything nested blanks regardless of tag/shape).
147
+ const scanTemplate = (forceBlank) => {
148
+ const tagged = isTagged();
149
+ let hasInterp = false, hasNewline = false, closed = false, depth = 0, k = i + 1;
150
+ while (k < n) {
151
+ const ch = src[k];
152
+ if (ch === '\\') { k += 2; continue; }
153
+ if (depth === 0 && ch === '`') { closed = true; break; }
154
+ if (ch === '$' && src[k + 1] === '{') { hasInterp = true; depth++; k += 2; continue; }
155
+ else if (ch === '{' && depth > 0) depth++;
156
+ else if (ch === '}' && depth > 0) depth--;
157
+ if (ch === '\n') hasNewline = true;
158
+ k++;
159
+ }
160
+ const verbatim = !forceBlank && !tagged && closed && !hasNewline && !hasInterp;
161
+ out += '`'; i++;
162
+ if (verbatim) {
163
+ while (i < n) {
164
+ if (src[i] === '\\' && i + 1 < n) { out += src[i] + src[i + 1]; i += 2; continue; }
165
+ if (src[i] === '`') { out += '`'; i++; break; }
166
+ out += src[i]; i++;
167
+ }
168
+ markValue();
169
+ return;
170
+ }
171
+ // Blanked template: blank the literal text, recurse through `${...}` holes
172
+ // (scanned as blanked code, so nested templates/strings/regexes inside a
173
+ // hole are delimited correctly and never desync the outer scan).
174
+ while (i < n) {
175
+ const c = src[i];
176
+ if (c === '\\' && i + 1 < n) { out += ' '; out += src[i + 1] === '\n' ? '\n' : ' '; i += 2; continue; }
177
+ if (c === '`') { out += '`'; i++; break; }
178
+ if (c === '$' && src[i + 1] === '{') {
179
+ out += ' '; i += 2;
180
+ scanCode(true, true);
181
+ if (i < n && src[i] === '}') { out += ' '; i++; }
182
+ continue;
183
+ }
184
+ out += c === '\n' ? '\n' : ' '; i++;
185
+ }
186
+ markValue();
187
+ };
188
+
189
+ // Scan code. `stopHole`: return at the `}` that closes the enclosing template
190
+ // hole (the caller emits it). `blank`: emit spaces for code (inside a blanked
191
+ // hole). Literals are always lexed so braces/quotes inside them never count.
192
+ function scanCode(stopHole, blank) {
193
+ let brace = 0;
194
+ while (i < n) {
195
+ const c = src[i], next = src[i + 1];
196
+ if (stopHole && c === '}' && brace === 0) return;
197
+ if (c === '/' && next === '/') { scanLineComment(); continue; }
198
+ if (c === '/' && next === '*') { scanBlockComment(); continue; }
199
+ if (c === '/' && isRegex()) { scanRegex(); continue; }
200
+ if (c === "'" || c === '"') { scanString(c, blank); continue; }
201
+ if (c === '`') { scanTemplate(blank); continue; }
202
+ if (c === '{') { brace++; lastSig = '{'; lastWord = ''; lastWasIncDec = false; out += blank ? ' ' : c; i++; continue; }
203
+ if (c === '}') { brace--; lastSig = '}'; lastWord = ''; lastWasIncDec = false; out += blank ? ' ' : c; i++; continue; }
204
+ if (/[A-Za-z_$]/.test(c)) {
205
+ const prop = lastSig === '.'; // member access -> a value, not a keyword
206
+ let w = '';
207
+ while (i < n && /[\w$]/.test(src[i])) { w += src[i]; out += blank ? ' ' : src[i]; i++; }
208
+ lastWord = w; lastSig = w[w.length - 1]; lastWordIsProp = prop; lastWasIncDec = false;
209
+ continue;
210
+ }
211
+ if (/\s/.test(c)) { out += c === '\n' ? '\n' : (blank ? ' ' : c); i++; continue; }
212
+ // A `++` / `--` repeats the operator char; the second one forms a postfix
213
+ // op when it followed a value (identifier / `)` / `]`), the only case that
214
+ // matters for the regex-vs-division decision here.
215
+ lastWasIncDec = (c === '+' || c === '-') && c === lastSig;
216
+ lastSig = c; lastWord = ''; out += blank ? ' ' : c; i++;
217
+ }
218
+ }
219
+
220
+ scanCode(false, false);
221
+ return out;
222
+ }
223
+
224
+ /**
225
+ * Extract the body of every `class … extends WebComponent { … }` block.
226
+ * Brace-counts to handle nested template literals, methods, and arrow
227
+ * functions. String state is tracked so braces inside strings/templates
228
+ * don't shift depth.
229
+ *
230
+ * @param {string} content
231
+ * @returns {string[]}
232
+ */
233
+ export function extractWebComponentClassBodies(content) {
234
+ const bodies = [];
235
+ const re = /class\s+\w+\s+extends\s+WebComponent\s*\{/g;
236
+ let m;
237
+ while ((m = re.exec(content)) !== null) {
238
+ const bodyStart = m.index + m[0].length;
239
+ const end = matchClosingBrace(content, bodyStart);
240
+ if (end !== -1) bodies.push(content.slice(bodyStart, end));
241
+ }
242
+ return bodies;
243
+ }
244
+
245
+ /**
246
+ * Walk forward from `start` (just after an opening `{`) and return the
247
+ * index of the matching `}`. Tracks string/template-literal state so
248
+ * `}` inside `'…'`, `"…"`, or backtick templates don't decrement depth.
249
+ * Returns -1 if no balanced brace is found.
250
+ *
251
+ * @param {string} s
252
+ * @param {number} start
253
+ */
254
+ export function matchClosingBrace(s, start) {
255
+ let depth = 1;
256
+ let i = start;
257
+ let str = ''; // '', "'", '"', or backtick
258
+ while (i < s.length) {
259
+ const c = s[i];
260
+ if (str) {
261
+ if (c === '\\') { i += 2; continue; }
262
+ if (c === str) str = '';
263
+ else if (str === '`' && c === '$' && s[i + 1] === '{') {
264
+ // template hole, count its closing `}` toward our brace depth.
265
+ depth++;
266
+ i += 2;
267
+ continue;
268
+ }
269
+ i++;
270
+ continue;
271
+ }
272
+ if (c === "'" || c === '"' || c === '`') { str = c; i++; continue; }
273
+ if (c === '/' && s[i + 1] === '/') { // line comment
274
+ while (i < s.length && s[i] !== '\n') i++;
275
+ continue;
276
+ }
277
+ if (c === '/' && s[i + 1] === '*') { // block comment
278
+ i += 2;
279
+ while (i < s.length && !(s[i] === '*' && s[i + 1] === '/')) i++;
280
+ i += 2;
281
+ continue;
282
+ }
283
+ if (c === '{') depth++;
284
+ else if (c === '}') { depth--; if (depth === 0) return i; }
285
+ i++;
286
+ }
287
+ return -1;
288
+ }