@webjsdev/server 0.7.2 → 0.8.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.
package/src/importmap.js CHANGED
@@ -1,40 +1,302 @@
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 at boot. Browser fetches vendor packages
21
+ * directly from jspm.io's CDN (default) or from local `/__webjs/vendor/`
22
+ * paths (after `webjs vendor pin --download`).
7
23
  */
8
24
 
9
25
  /** @type {Record<string, string>} */
10
26
  let _extraEntries = {};
11
27
 
12
28
  /**
13
- * Merge additional vendor entries into the import map.
14
- * Called by the server after scanning for bare imports.
29
+ * SRI integrity hashes keyed by FINAL URL (post-importmap-rewrite).
30
+ * Populated only when a pin file with `integrity` is present;
31
+ * live-API mode skips it.
32
+ * @type {Record<string, string>}
33
+ */
34
+ let _vendorIntegrity = {};
35
+
36
+ /**
37
+ * Merge additional vendor entries into the import map and precompute
38
+ * the importmap-hash so `importMapHash()` can stay synchronous on the
39
+ * per-request SSR hot path. Called by the dev server at boot and on
40
+ * every vendor rebuild.
41
+ *
15
42
  * @param {Record<string, string>} entries
43
+ * @param {Record<string, string>} [integrity] SRI hashes keyed by URL
44
+ * @returns {Promise<void>}
16
45
  */
17
- export function setVendorEntries(entries) {
46
+ export async function setVendorEntries(entries, integrity) {
18
47
  _extraEntries = entries;
48
+ _vendorIntegrity = integrity || {};
49
+ _importMapHash = await digestHex('SHA-256', JSON.stringify(buildImportMap()));
50
+ }
51
+
52
+ /**
53
+ * Stable SHA-256 of the current importmap JSON, used as the
54
+ * `data-webjs-build` attribute on `<script type="importmap">` and
55
+ * as the `X-Webjs-Build` response header on every SSR response.
56
+ *
57
+ * Purpose: the X-Webjs-Have partial-response optimization in ssr.js
58
+ * short-circuits at the outermost cached layout and returns only the
59
+ * inner body (no head, no importmap). Without the build header the
60
+ * client router has no way to detect a deploy that bumped the
61
+ * importmap. After a `webjs vendor pin` rerun the user's next
62
+ * intra-shell nav would stay on the stale importmap and the new
63
+ * vendor URLs would never load. The header lets applySwap detect
64
+ * the change and hard-reload before applying the swap.
65
+ *
66
+ * Synchronous accessor. The hash is precomputed eagerly inside
67
+ * `setVendorEntries` (which the dev server `await`s during boot and
68
+ * on every rebuild) so the per-request SSR hot path can return the
69
+ * cached string without crossing a Promise boundary.
70
+ *
71
+ * Returns an empty string if `setVendorEntries` has never run; the
72
+ * client router treats an empty `X-Webjs-Build` as "version unknown"
73
+ * and skips the importmap drift check, which is the right behaviour
74
+ * for tests / embedding contexts that never set vendor entries.
75
+ *
76
+ * @returns {string} e.g. `abc123…` (hex, 64 chars)
77
+ */
78
+ let _importMapHash = '';
79
+ export function importMapHash() {
80
+ return _importMapHash;
81
+ }
82
+
83
+ /**
84
+ * Look up the SRI integrity hash for a vendor URL, or empty string if
85
+ * none. Used by ssr.js to add `integrity="..."` to modulepreload tags
86
+ * pointing at vendor URLs.
87
+ *
88
+ * @param {string} url
89
+ * @returns {string}
90
+ */
91
+ export function vendorIntegrityFor(url) {
92
+ return _vendorIntegrity[url] || '';
93
+ }
94
+
95
+ /**
96
+ * The `@webjsdev/core` install's importmap entries, derived from its
97
+ * own `package.json` `exports` field. Populated by `setCoreInstall`
98
+ * at boot.
99
+ *
100
+ * Initialized to the two minimum-safe defaults (the bare specifier
101
+ * pointing at the browser source-mode entry and the catch-all prefix
102
+ * pointing at `src/`) so any consumer that calls `buildImportMap()`
103
+ * before `setCoreInstall` runs still gets a usable map. Pre-#118 the
104
+ * legacy `coreMappings` were always derived from a boolean and so
105
+ * could not be empty; this keeps that fail-open posture for embedded
106
+ * SSR test helpers and one-shot tooling that imports `importmap.js`
107
+ * without booting `dev.js`.
108
+ *
109
+ * @type {Record<string, string>}
110
+ */
111
+ let _coreEntries = {
112
+ '@webjsdev/core': '/__webjs/core/index-browser.js',
113
+ '@webjsdev/core/': '/__webjs/core/src/',
114
+ };
115
+
116
+ /**
117
+ * Bind the importmap to a specific `@webjsdev/core` install. The
118
+ * builder reads the package's `package.json` exports field once and
119
+ * derives one importmap entry per exported subpath, picking the
120
+ * `default` (bundled `dist/`) condition when `distMode` is true and
121
+ * the `source` (per-file `src/`) condition when it's false. The
122
+ * derivation lets the framework drop the 9-line hardcoded mapping
123
+ * table that used to live here, and means the importmap follows the
124
+ * shipped package whenever subpaths are renamed or added.
125
+ *
126
+ * The bare `@webjsdev/core` specifier still hardcodes its target
127
+ * (the browser-only entry shipped at `index-browser.js` /
128
+ * `dist/webjs-core-browser.js`) because that file is not declared
129
+ * in the exports field, by design: it is a server-stripped surface
130
+ * meant for the importmap-driven browser route, not Node resolution.
131
+ *
132
+ * Called once by `dev.js` at boot. Not re-called on file-watcher
133
+ * rebuilds today; if `@webjsdev/core/package.json` is edited in a
134
+ * long-running dev session (e.g. workspace dev that runs a fresh
135
+ * `npm run build:dist`), the derivation is refreshed on next server
136
+ * restart, not on the watcher tick. Pre-#118 the legacy
137
+ * `setCoreDistMode` had the same behaviour: only the dist-presence
138
+ * boolean was watched, not the package.json itself.
139
+ *
140
+ * Like `setVendorEntries`, the importmap-hash is recomputed eagerly
141
+ * so `importMapHash()` stays synchronous on the per-request SSR
142
+ * hot path.
143
+ *
144
+ * @param {string} coreDir absolute path to the resolved `@webjsdev/core` install
145
+ * @param {boolean} distMode true when both `webjs-core.js` and `webjs-core-browser.js` exist in `dist/`
146
+ * @returns {Promise<void>}
147
+ */
148
+ export async function setCoreInstall(coreDir, distMode) {
149
+ _coreEntries = buildCoreEntries(coreDir, !!distMode);
150
+ _importMapHash = await digestHex('SHA-256', JSON.stringify(buildImportMap()));
151
+ }
152
+
153
+ /**
154
+ * Read `<coreDir>/package.json` and derive importmap entries from
155
+ * its `exports` field. The function is pure (no side effects) and
156
+ * exported so tests can exercise the derivation directly.
157
+ *
158
+ * For each subpath in `exports` whose value is an object form, emit
159
+ * one entry. Pick the `default` value in dist mode (a bundled
160
+ * `dist/webjs-core-*.js`) and the `source` value in src mode (a
161
+ * per-file `src/*.js`). When `source` is absent (e.g. `./component`,
162
+ * whose shape is `{ types, default }` and whose `default` is itself
163
+ * a `src/` path), fall back to `default` in src mode so the import
164
+ * still resolves with a `.js` extension on the URL.
165
+ *
166
+ * Subpaths with a plain string value (`./client`, `./server`,
167
+ * `./registry`, `./signals`, `./package.json`) are not mapped
168
+ * explicitly; the catch-all `@webjsdev/core/` prefix routes them
169
+ * through `/__webjs/core/src/`. Future-added subpaths added in
170
+ * string form land on the catch-all the same way.
171
+ *
172
+ * Path-traversal guard: any `default` / `source` value that contains
173
+ * `..` is skipped. The trust boundary today is the framework's own
174
+ * `@webjsdev/core/package.json`, but the guard makes a future shift
175
+ * to user-controlled `coreDir` (e.g. via a `--core-dir` flag) safe
176
+ * by construction.
177
+ *
178
+ * @param {string} coreDir
179
+ * @param {boolean} distMode
180
+ * @returns {Record<string, string>}
181
+ */
182
+ export function buildCoreEntries(coreDir, distMode) {
183
+ /** @type {Record<string, string>} */
184
+ const out = {
185
+ // Bare specifier: browser-only entry, slim by design (drops
186
+ // render-server, expose, setCspNonceProvider). Node-side
187
+ // consumers resolve via the package.json exports `default`
188
+ // condition and land on `index.js` instead.
189
+ '@webjsdev/core': distMode
190
+ ? '/__webjs/core/dist/webjs-core-browser.js'
191
+ : '/__webjs/core/index-browser.js',
192
+ // Catch-all: source-only subpaths (`./client`, `./server`,
193
+ // `./component`, `./registry`, `./signals`) and any future
194
+ // subpath not yet enumerated by exports still resolve.
195
+ '@webjsdev/core/': '/__webjs/core/src/',
196
+ };
197
+ let pkg;
198
+ try {
199
+ pkg = JSON.parse(readFileSync(join(coreDir, 'package.json'), 'utf8'));
200
+ } catch {
201
+ // Without a readable package.json the bare + catch-all entries
202
+ // above are the minimum useful map. Per-subpath entries stay
203
+ // missing; the catch-all picks them up at src/<name> (callers
204
+ // would need to include the .js extension in their import).
205
+ return out;
206
+ }
207
+ const exportsField = pkg && pkg.exports;
208
+ if (!exportsField || typeof exportsField !== 'object') return out;
209
+ for (const [subpath, entry] of Object.entries(exportsField)) {
210
+ // Skip the bare `.` (handled above) and any non-subpath key.
211
+ if (subpath === '.' || !subpath.startsWith('./') || subpath.endsWith('/')) continue;
212
+ // Only object-form entries with both default and source carry
213
+ // a dist mapping. String-form entries (`./client`,
214
+ // `./component` once it loses its types, …) stay on the
215
+ // catch-all.
216
+ if (!entry || typeof entry !== 'object') continue;
217
+ // Pick the condition for the requested mode. In src mode,
218
+ // entries that lack a `source` (e.g. `./component`, whose
219
+ // package.json shape is `{ types, default }`) fall back to
220
+ // `entry.default`. The fallback URL is still a `src/` path on
221
+ // those entries, so it resolves correctly without forcing users
222
+ // to add a `.js` extension to subpath imports.
223
+ let targetRel = distMode ? entry.default : entry.source;
224
+ if (typeof targetRel !== 'string') targetRel = entry.default;
225
+ if (typeof targetRel !== 'string' || !targetRel.startsWith('./')) continue;
226
+ // Reject paths containing `..` to guard against a malformed or
227
+ // adversarial `exports` field producing a path-traversal URL.
228
+ // The check is deliberately broad: `..` substring catches both
229
+ // `../etc/passwd` and `./foo/../bar`.
230
+ if (targetRel.includes('..')) continue;
231
+ // `./directives` → `@webjsdev/core/directives`,
232
+ // `./dist/webjs-core-directives.js` → `/__webjs/core/dist/webjs-core-directives.js`.
233
+ out['@webjsdev/core' + subpath.slice(1)] = '/__webjs/core/' + targetRel.slice(2);
234
+ }
235
+ return out;
19
236
  }
20
237
 
21
238
  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
- },
239
+ const merged = {
240
+ ..._coreEntries,
241
+ ..._extraEntries,
34
242
  };
243
+ // Sort keys so logically-identical importmaps serialize byte-for-byte
244
+ // identically. The client router compares textContent to detect
245
+ // post-deploy importmap mismatches; without a stable order the
246
+ // scanner's filesystem-iteration order could change between deploys
247
+ // (e.g. after a file rename) and trigger a spurious hard reload
248
+ // even though the content didn't actually change.
249
+ /** @type {Record<string, string>} */
250
+ const imports = {};
251
+ for (const k of Object.keys(merged).sort()) imports[k] = merged[k];
252
+
253
+ // Emit `integrity` per the importmap-integrity spec (Chrome 132+,
254
+ // Safari 18.4+, Firefox flagged). Browsers without support ignore
255
+ // the field; per-tag SRI on modulepreload covers them.
256
+ //
257
+ // Filter orphan integrity entries (URLs that aren't actually in
258
+ // imports). The browser only consults integrity for URLs that
259
+ // resolve through the importmap, so orphans are harmless but bloat
260
+ // the JSON, defeat the importMapHash stability invariant on
261
+ // unrelated pin file edits, and leak removed URLs to the wire.
262
+ const out = { imports };
263
+ const usedUrls = new Set(Object.values(imports));
264
+ const intKeys = Object.keys(_vendorIntegrity).filter(k => usedUrls.has(k)).sort();
265
+ if (intKeys.length) {
266
+ /** @type {Record<string, string>} */
267
+ const integrity = {};
268
+ for (const k of intKeys) integrity[k] = _vendorIntegrity[k];
269
+ out.integrity = integrity;
270
+ }
271
+ return out;
35
272
  }
36
273
 
37
- /** Serialise the import map to an HTML script tag string. */
38
- export function importMapTag() {
39
- return `<script type="importmap">${JSON.stringify(buildImportMap())}</script>`;
274
+ /**
275
+ * Serialise the import map to an HTML script tag string.
276
+ *
277
+ * When `nonce` is provided (extracted from the incoming
278
+ * Content-Security-Policy header by ssr.js), it's emitted as
279
+ * `nonce="..."` on the script tag. Strict-CSP apps using
280
+ * `script-src 'nonce-...'` require this; without it the browser
281
+ * blocks the importmap and every bare-specifier import fails.
282
+ *
283
+ * Defense-in-depth: JSON content is run through `jsonForScriptTag`
284
+ * so a string value containing `</script>` (e.g. a maliciously
285
+ * crafted vendor URL that somehow slipped past the jspm.io filter)
286
+ * cannot close the importmap tag early and inject script content.
287
+ *
288
+ * @param {{ nonce?: string }} [opts]
289
+ */
290
+ export function importMapTag(opts = {}) {
291
+ // Full attribute escape, not just `"` to `&quot;`. The nonce arrives
292
+ // from the request's CSP header (parsed by ssr.js), which we treat
293
+ // as untrusted input even though CSP spec restricts nonce charset to
294
+ // base64-ish. A misconfigured upstream emitting `nonce-<bad>` should
295
+ // not get its `<` rendered raw into our HTML.
296
+ const n = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : '';
297
+ // Stamp the build hash so the client router can detect post-deploy
298
+ // importmap changes on intra-shell partial-response navigations.
299
+ // See importMapHash() above for the rationale.
300
+ const b = ` data-webjs-build="${importMapHash()}"`;
301
+ return `<script type="importmap"${n}${b}>${jsonForScriptTag(buildImportMap())}</script>`;
40
302
  }
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
+ }