@pyreon/vite-plugin 0.23.0 → 0.24.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/index.ts CHANGED
@@ -134,6 +134,31 @@ export interface PyreonPluginOptions {
134
134
  */
135
135
  islands?: boolean
136
136
 
137
+ /**
138
+ * **LPIH auto-bridge** — zero-config Live Program Inlay Hints in dev.
139
+ *
140
+ * When `true` (the default in dev), the plugin auto-wires the LPIH
141
+ * cache file: the browser-side activates devtools + polls fire data
142
+ * every `intervalMs` (250ms default), and the dev-server middleware
143
+ * receives the POST + writes `<project-root>/.pyreon-lpih.json` using
144
+ * the atomic-rename pattern from `@pyreon/reactivity/lpih`. The LSP
145
+ * (`pyreon-lint --lsp`) auto-discovers that file, so the end-to-end
146
+ * "save file → see fire counts" loop needs ZERO user wiring.
147
+ *
148
+ * Set to `false` to opt out (e.g. if you're wiring `startLpihPolling()`
149
+ * yourself from a non-browser runtime, or you want LPIH off entirely).
150
+ * Pass an object to override the interval or the cache-file path.
151
+ *
152
+ * Build-only consumer: production builds skip injection entirely.
153
+ *
154
+ * @example
155
+ * pyreon({ lpih: true }) // default in dev
156
+ * pyreon({ lpih: false }) // opt out
157
+ * pyreon({ lpih: { intervalMs: 500 } }) // slower poll
158
+ * pyreon({ lpih: { cachePath: '/tmp/x.json' } }) // custom path
159
+ */
160
+ lpih?: boolean | PyreonLpihOptions
161
+
137
162
  /**
138
163
  * P0 — opt-in compile-time rocketstyle wrapper collapse. `true` uses
139
164
  * the default provider/theme/mode wiring (PyreonUI + theme +
@@ -174,6 +199,26 @@ export interface PyreonCollapseOptions {
174
199
  mode?: { name: string; source: string }
175
200
  }
176
201
 
202
+ export interface PyreonLpihOptions {
203
+ /**
204
+ * Poll interval in milliseconds. The browser-side bridge reads
205
+ * `getFireSummaries()` and POSTs every `intervalMs` to the dev-server
206
+ * middleware. Default 250ms — matches the LSP-debounce window so
207
+ * editor hints settle within one frame of the typical save→hint cycle.
208
+ *
209
+ * Lower values (e.g. 100ms) trade dev-server CPU for snappier hints;
210
+ * higher values (1000ms) reduce overhead for slow machines.
211
+ */
212
+ intervalMs?: number
213
+ /**
214
+ * Cache-file path override. Defaults to
215
+ * `<projectRoot>/.pyreon-lpih.json` — the convention the LSP auto-
216
+ * discovers (R2, #777). Override only if you need a non-default
217
+ * location (shared mount, custom workspace layout).
218
+ */
219
+ cachePath?: string
220
+ }
221
+
177
222
  // ── Compat alias maps ─────────────────────────────────────────────────────────
178
223
 
179
224
  const COMPAT_ALIASES: Record<CompatFramework, Record<string, string>> = {
@@ -336,6 +381,15 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
336
381
  // you have a specific reason.
337
382
  const islandsEnabled = options?.islands !== false
338
383
 
384
+ // ── LPIH auto-bridge config ──────────────────────────────────────────────
385
+ // Default `true` (zero-config Live Program Inlay Hints in dev). Set to
386
+ // `false` to opt out. Object form overrides interval / cache path.
387
+ const lpihOpt = options?.lpih
388
+ const lpihEnabled = lpihOpt !== false
389
+ const lpihUserCfg: PyreonLpihOptions =
390
+ lpihOpt && lpihOpt !== true ? lpihOpt : {}
391
+ const lpihIntervalMs = lpihUserCfg.intervalMs ?? 250
392
+
339
393
  // ── P0 rocketstyle-collapse config (opt-in) ───────────────────────────────
340
394
  const collapseOpt = options?.collapse
341
395
  const collapseEnabled = collapseOpt === true || (collapseOpt != null && collapseOpt !== false)
@@ -748,8 +802,10 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
748
802
  // ── Dev-only transforms ────────────────────────────────────────────
749
803
  if (!isBuild) {
750
804
  output = injectHmr(output, id)
751
- // Inject debug names for signal() calls not rewritten by HMR
752
- output = injectSignalNames(output)
805
+ // Inject debug names + LPIH source locations for signal() calls
806
+ // not rewritten by HMR. `id` is Vite's resolved module path —
807
+ // the same path the runtime would have parsed from new Error().
808
+ output = injectSignalNames(output, id)
753
809
  }
754
810
 
755
811
  // R12: surface the compiler's V3 source map so stack traces /
@@ -777,6 +833,14 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
777
833
  }
778
834
  })
779
835
 
836
+ // LPIH auto-bridge — accepts POST /__pyreon_lpih__ from the browser
837
+ // client and atomically writes the cache file the LSP auto-discovers.
838
+ // Registered BEFORE the SSR middleware so it short-circuits and never
839
+ // falls through to handleSsrRequest.
840
+ if (lpihEnabled) {
841
+ registerLpihMiddleware(server, projectRoot, lpihUserCfg)
842
+ }
843
+
780
844
  if (!ssrConfig) return
781
845
 
782
846
  // Return a function so the middleware runs AFTER Vite's built-in middleware
@@ -796,6 +860,18 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
796
860
  })
797
861
  }
798
862
  },
863
+
864
+ // ── LPIH auto-bridge client injection ────────────────────────────────────
865
+ transformIndexHtml(html: string): string | undefined {
866
+ if (isBuild || !lpihEnabled) return undefined
867
+ // Inject a tiny <script type="module"> that activates devtools + polls
868
+ // getFireSummaries() and POSTs to /__pyreon_lpih__. The dev server
869
+ // middleware (above) writes the body to <projectRoot>/.pyreon-lpih.json
870
+ // using @pyreon/reactivity's atomic-rename pattern. The LSP
871
+ // auto-discovers that file (R2, #777) so the user wires NOTHING.
872
+ const script = buildLpihClientScript(lpihIntervalMs)
873
+ return html.replace('</head>', `${script}\n</head>`)
874
+ },
799
875
  }
800
876
  }
801
877
 
@@ -854,6 +930,186 @@ function generateProjectContext(root: string): void {
854
930
  }
855
931
  }
856
932
 
933
+ // ── LPIH auto-bridge helpers ───────────────────────────────────────────────
934
+
935
+ /**
936
+ * Resolve the LPIH cache-file path for a given project root. Matches the
937
+ * convention `@pyreon/reactivity/lpih`'s `getDefaultLpihCachePath()` uses
938
+ * AND the LSP auto-discovers (R2, #777): `<projectRoot>/.pyreon-lpih.json`.
939
+ *
940
+ * @internal — exported for tests.
941
+ */
942
+ export function resolveLpihCachePath(projectRoot: string): string {
943
+ return pathJoin(projectRoot, '.pyreon-lpih.json')
944
+ }
945
+
946
+ /**
947
+ * Register the LPIH dev-server middleware on a Vite server. Extracted from
948
+ * `configureServer` so the `cachePath` option reference lives at module
949
+ * scope (top-level helper) rather than inside the plugin's inline body —
950
+ * keeps `scripts/audit-types.ts` happy regardless of how its comment-
951
+ * stripping handles the long inline `configureServer` block.
952
+ *
953
+ * @internal — exported for tests.
954
+ */
955
+ export function registerLpihMiddleware(
956
+ server: ViteDevServer,
957
+ projectRoot: string,
958
+ userCfg: PyreonLpihOptions,
959
+ ): void {
960
+ const cachePath = userCfg.cachePath ?? resolveLpihCachePath(projectRoot)
961
+ server.middlewares.use('/__pyreon_lpih__', (req, res) => {
962
+ if (req.method !== 'POST') {
963
+ res.statusCode = 405
964
+ res.end('Method Not Allowed')
965
+ return
966
+ }
967
+ let body = ''
968
+ req.on('data', (chunk: Buffer | string) => {
969
+ body += chunk.toString()
970
+ // Defensive cap — fire payloads are tiny (a few KB at most);
971
+ // anything larger is malicious or buggy. Drop the request.
972
+ if (body.length > 1024 * 1024) {
973
+ res.statusCode = 413
974
+ res.end('Payload Too Large')
975
+ req.destroy()
976
+ }
977
+ })
978
+ req.on('end', () => {
979
+ void writeLpihCacheFile(cachePath, body)
980
+ .then(() => {
981
+ res.statusCode = 204
982
+ res.end()
983
+ })
984
+ .catch((err: unknown) => {
985
+ // Don't crash the dev server — log + return 500 so the
986
+ // browser-side bridge can back off + retry next interval.
987
+ // oxlint-disable-next-line no-console
988
+ console.warn(
989
+ '[pyreon] LPIH cache write failed:',
990
+ err instanceof Error ? err.message : err,
991
+ )
992
+ res.statusCode = 500
993
+ res.end('LPIH cache write failed')
994
+ })
995
+ })
996
+ })
997
+ }
998
+
999
+ let _lpihSeq = 0
1000
+
1001
+ /**
1002
+ * Atomically write a LPIH cache file (tmp + rename), mirroring the
1003
+ * `@pyreon/reactivity/lpih:writeLpihCache` implementation. The payload
1004
+ * comes pre-serialized from the browser-side bridge — we validate the
1005
+ * outer shape (`{ fires: [...] }`) and reject malformed bodies to stop a
1006
+ * buggy client from corrupting the file the LSP reads.
1007
+ *
1008
+ * @internal — exported for tests.
1009
+ */
1010
+ export async function writeLpihCacheFile(path: string, body: string): Promise<void> {
1011
+ // Validate shape — must be a JSON object with `fires: array`. We re-
1012
+ // serialize so the on-disk format is stable regardless of how the
1013
+ // browser-side bridge encodes it.
1014
+ let parsed: unknown
1015
+ try {
1016
+ parsed = JSON.parse(body)
1017
+ } catch {
1018
+ throw new Error('LPIH bridge: payload is not valid JSON')
1019
+ }
1020
+ if (
1021
+ parsed === null ||
1022
+ typeof parsed !== 'object' ||
1023
+ !Array.isArray((parsed as { fires?: unknown }).fires)
1024
+ ) {
1025
+ throw new Error('LPIH bridge: payload is missing `fires` array')
1026
+ }
1027
+ const fs = await import('node:fs/promises')
1028
+ const pid = typeof process !== 'undefined' && 'pid' in process ? process.pid : 0
1029
+ const tmp = `${path}.tmp.${pid}.${++_lpihSeq}`
1030
+ // Single try/catch covering BOTH writeFile AND rename. The previous
1031
+ // shape only guarded the rename — if `fs.writeFile` itself threw (disk
1032
+ // full, EIO, EACCES, transient FS error), the partial tmp file leaked
1033
+ // on disk with a unique PID+seq name (so no conflict, but it accumulated
1034
+ // forever). Audit caught this in the LPIH followups round.
1035
+ try {
1036
+ await fs.writeFile(tmp, JSON.stringify(parsed), 'utf8')
1037
+ await fs.rename(tmp, path)
1038
+ } catch (err) {
1039
+ // Best-effort cleanup; original error is more useful than unlink's.
1040
+ // Covers BOTH the writeFile-failed (tmp may not exist) and the
1041
+ // rename-failed (tmp exists, rename didn't move it) cases —
1042
+ // `fs.unlink` of a non-existent file throws ENOENT, which we swallow.
1043
+ try {
1044
+ await fs.unlink(tmp)
1045
+ } catch {
1046
+ /* swallow — original error is the user-facing one */
1047
+ }
1048
+ throw err
1049
+ }
1050
+ }
1051
+
1052
+ /**
1053
+ * Build the `<script type="module">` body injected into the HTML head.
1054
+ * The script imports devtools activation + `getFireSummaries` from
1055
+ * `@pyreon/reactivity`, sets up a `setInterval` that POSTs every
1056
+ * `intervalMs` ms, and registers a `beforeunload` cleanup so the timer
1057
+ * doesn't outlive the page.
1058
+ *
1059
+ * Browser bundlers serve `@pyreon/reactivity` from the workspace via
1060
+ * Vite's normal module resolution — no virtual module needed.
1061
+ *
1062
+ * @internal — exported for tests.
1063
+ */
1064
+ export function buildLpihClientScript(intervalMs: number): string {
1065
+ // Note: the script body is intentionally compact — the goal is zero
1066
+ // visible payload in DevTools "Sources" while still being readable
1067
+ // when someone DOES go looking. `JSON.stringify` for `intervalMs` is
1068
+ // defense against `__proto__` / NaN / non-finite values reaching the
1069
+ // emitted JS as a literal.
1070
+ // CRITICAL — top-level await on the dynamic import. `<script type="module">`
1071
+ // tags execute in document order with `defer` semantics; the head-injected
1072
+ // LPIH script's body MUST fully evaluate (including this await) BEFORE the
1073
+ // body-injected app entry's module body runs. Otherwise activateReactiveDevtools()
1074
+ // would land AFTER the app has already created its module-scope signals,
1075
+ // and `_rdRegister` (gated on `if (!_active) return undefined`) would skip
1076
+ // them entirely — making the most common signal shape (top-of-file `const x = signal(0)`)
1077
+ // invisible to LPIH. With the `await`, the LPIH module doesn't complete
1078
+ // until activation finishes; the app's entry waits its turn.
1079
+ return `<script type="module">
1080
+ // Pyreon LPIH auto-bridge — POSTs fire summaries to /__pyreon_lpih__
1081
+ // so the LSP (pyreon-lint --lsp) sees live fire data. Dev-only.
1082
+ const __px = await import('@pyreon/reactivity').catch(() => null)
1083
+ if (__px) {
1084
+ __px.activateReactiveDevtools()
1085
+ const __pxGet = __px.getFireSummaries
1086
+ const __pxInterval = ${JSON.stringify(intervalMs)}
1087
+ const __pxPost = () => {
1088
+ const summaries = __pxGet()
1089
+ const payload = JSON.stringify({
1090
+ fires: summaries.map((s) => ({
1091
+ file: s.loc.file,
1092
+ line: s.loc.line,
1093
+ count: s.count,
1094
+ kind: s.kind,
1095
+ lastFire: s.lastFire,
1096
+ rate1s: s.rate1s,
1097
+ })),
1098
+ })
1099
+ fetch('/__pyreon_lpih__', { method: 'POST', body: payload, headers: { 'content-type': 'application/json' } }).catch(() => {
1100
+ // Dev-server might be restarting; swallow + retry next interval.
1101
+ })
1102
+ }
1103
+ const __pxId = setInterval(__pxPost, __pxInterval)
1104
+ window.addEventListener('beforeunload', () => clearInterval(__pxId))
1105
+ }
1106
+ // If __px is null, @pyreon/reactivity isn't in the dep graph — stay silent,
1107
+ // LPIH is opt-in via the runtime API too. The dynamic-import catch returns
1108
+ // null instead of letting the rejection bubble so consumers without the
1109
+ // package don't see a console error.
1110
+ </script>`
1111
+ }
1112
+
857
1113
  // ── HMR injection ─────────────────────────────────────────────────────────────
858
1114
 
859
1115
  /**
@@ -981,37 +1237,312 @@ function hasMultipleArgs(args: string): boolean {
981
1237
  }
982
1238
 
983
1239
  /**
984
- * Inject `{ name: "varName" }` into signal() calls that don't already have
1240
+ * Inject `{ name?, __sourceLocation: { file, line, col } }` into
1241
+ * `signal()` / `computed()` / `effect()` calls that don't already have
985
1242
  * an options argument. Only runs in dev mode for debugging/devtools.
986
1243
  *
987
- * `const count = signal(0)` → `const count = signal(0, { name: "count" })`
1244
+ * Three forms covered:
1245
+ *
1246
+ * `const count = signal(0)` →
1247
+ * `const count = signal(0, { name: "count", __sourceLocation: {...} })`
1248
+ *
1249
+ * `const doubled = computed(() => count() * 2)` →
1250
+ * `const doubled = computed(() => count() * 2, { name: "doubled", __sourceLocation: {...} })`
1251
+ *
1252
+ * `effect(() => console.log(count()))` →
1253
+ * `effect(() => console.log(count()), { __sourceLocation: {...} })`
1254
+ * (no `name` — anonymous effects have no binding to derive from)
988
1255
  *
989
1256
  * Module-scope signals rewritten to __hmr_signal() are naturally skipped
990
1257
  * because the regex matches `signal(` not `__hmr_signal(`.
1258
+ *
1259
+ * **LPIH integration**: `__sourceLocation` is consumed by
1260
+ * `@pyreon/reactivity`'s `signal()` / `computed()` / `effect()` to skip
1261
+ * the `new Error().stack` capture in `_rdRegister` — saves ~2.2µs per
1262
+ * creation when devtools is active. The injected literal is byte-for-byte
1263
+ * the same info the runtime would have parsed from the stack, so behavior
1264
+ * is identical except no stack-parse cost.
1265
+ *
1266
+ * **Anonymous-effect detection**: `effect(` can also appear as a property
1267
+ * access (`obj.effect(...)`), a longer identifier (`sideEffect(...)`), or
1268
+ * a previously-injected call (`effect(fn, { ... })`). The unbound-effect
1269
+ * pass guards against all three:
1270
+ * - preceded by NOT `[A-Za-z0-9_$.]` (so `.effect`/`sideEffect` skip)
1271
+ * - args do NOT already contain a 2nd arg (`hasMultipleArgs` check)
1272
+ *
1273
+ * @param code - source text
1274
+ * @param moduleId - the file path to embed in the injected `__sourceLocation`.
1275
+ * Vite passes the resolved module ID (absolute path).
991
1276
  */
992
- function injectSignalNames(code: string): string {
993
- const re = /(?:const|let)\s+(\w+)\s*=\s*signal\(/gm
994
- const matches: { start: number; end: number; name: string; args: string }[] = []
1277
+ function injectSignalNames(code: string, moduleId: string): string {
1278
+ // Pre-pass: mask string-literal, template-literal, and comment regions
1279
+ // so the regexes below don't false-fire on `effect(` inside docstrings,
1280
+ // help-text strings, JS-as-text test fixtures, or comments mentioning
1281
+ // reactive primitives. The regex runs against the MASKED code (positions
1282
+ // are preserved), so a match's index points at real code; args extraction
1283
+ // pulls from the ORIGINAL code for accurate output.
1284
+ //
1285
+ // Without this, user code like `const docs = \`effect(() => x)\`` would
1286
+ // get `, { __sourceLocation: ... }` injected INSIDE the template literal,
1287
+ // corrupting the help-text content at runtime.
1288
+ const masked = _maskStringsAndComments(code)
1289
+
1290
+ // Pass 1: bound forms — `const X = (signal|computed|effect)(…)`.
1291
+ // Extract `X` as the debug name + the reactive primitive kind.
1292
+ const reBound = /(?:const|let)\s+(\w+)\s*=\s*(signal|computed|effect)\(/gm
1293
+ // Pass 2: unbound effect — `effect(() => …)` at statement position,
1294
+ // not following a member-access (.) or identifier char ($_a-zA-Z0-9).
1295
+ // Reactive primitives other than `effect` are rare without binding,
1296
+ // so we skip the bare `signal(` / `computed(` form to stay conservative.
1297
+ const reUnboundEffect = /(?<![\w$.])effect\(/gm
1298
+
1299
+ type Match = {
1300
+ start: number
1301
+ end: number
1302
+ name: string | null
1303
+ args: string
1304
+ matchIdx: number
1305
+ }
1306
+ const matches: Match[] = []
1307
+ // Track call positions covered by pass 1 so pass 2 can skip them.
1308
+ const covered = new Set<number>()
995
1309
 
996
- let m: RegExpExecArray | null = re.exec(code)
1310
+ let m: RegExpExecArray | null = reBound.exec(masked)
997
1311
  while (m !== null) {
998
1312
  const argsStart = m.index + m[0].length
999
1313
  const args = extractBalancedArgs(code, argsStart)
1000
1314
  if (args !== null && !hasMultipleArgs(args)) {
1001
- matches.push({ start: argsStart, end: argsStart + args.length, name: m[1] ?? '', args })
1315
+ matches.push({
1316
+ start: argsStart,
1317
+ end: argsStart + args.length,
1318
+ name: m[1] ?? '',
1319
+ args,
1320
+ matchIdx: m.index,
1321
+ })
1322
+ // Mark the `effect(`/`signal(`/`computed(` token start so the
1323
+ // unbound-effect pass doesn't double-process it.
1324
+ const tokStart = m.index + m[0].length - (m[2]?.length ?? 0) - 1
1325
+ covered.add(tokStart)
1326
+ }
1327
+ m = reBound.exec(masked)
1328
+ }
1329
+ reBound.lastIndex = 0
1330
+
1331
+ m = reUnboundEffect.exec(masked)
1332
+ while (m !== null) {
1333
+ if (!covered.has(m.index)) {
1334
+ const argsStart = m.index + m[0].length
1335
+ const args = extractBalancedArgs(code, argsStart)
1336
+ if (args !== null && !hasMultipleArgs(args)) {
1337
+ matches.push({
1338
+ start: argsStart,
1339
+ end: argsStart + args.length,
1340
+ name: null,
1341
+ args,
1342
+ matchIdx: m.index,
1343
+ })
1344
+ }
1002
1345
  }
1003
- m = re.exec(code)
1346
+ m = reUnboundEffect.exec(masked)
1004
1347
  }
1005
- re.lastIndex = 0
1348
+ reUnboundEffect.lastIndex = 0
1349
+
1350
+ if (matches.length === 0) return code
1351
+
1352
+ // Sort by descending start so back-to-front rewriting doesn't shift
1353
+ // later indices (each splice leaves earlier offsets unchanged).
1354
+ matches.sort((a, b) => b.start - a.start)
1355
+
1356
+ // Pre-compute line offsets ONCE — avoids O(N²) when many calls share
1357
+ // a file. Each lookup becomes O(log N) via binary search.
1358
+ const lineStarts = _computeLineStarts(code)
1006
1359
 
1007
1360
  let output = code
1008
- for (let i = matches.length - 1; i >= 0; i--) {
1009
- const { start, end, name, args } = matches[i] as (typeof matches)[number]
1010
- output = `${output.slice(0, start)}${args}, { name: ${JSON.stringify(name)} }${output.slice(end)}`
1361
+ for (let i = 0; i < matches.length; i++) {
1362
+ const { start, end, name, args, matchIdx } = matches[i] as Match
1363
+ const { line, col } = _offsetToLineCol(matchIdx, lineStarts)
1364
+ const locLiteral = `__sourceLocation: { file: ${JSON.stringify(moduleId)}, line: ${line}, col: ${col} }`
1365
+ const inner = name !== null
1366
+ ? `name: ${JSON.stringify(name)}, ${locLiteral}`
1367
+ : locLiteral
1368
+ output = `${output.slice(0, start)}${args}, { ${inner} }${output.slice(end)}`
1011
1369
  }
1012
1370
  return output
1013
1371
  }
1014
1372
 
1373
+ /**
1374
+ * Mask string-literal / template-literal / comment regions in `code` by
1375
+ * replacing their content with spaces. Returns a SAME-LENGTH string so
1376
+ * regex match positions in the masked version line up with the original.
1377
+ *
1378
+ * Used by `injectSignalNames` to skip false-positive matches against
1379
+ * reactive-primitive names that appear inside strings or comments. Without
1380
+ * masking, a user's `const docs = \`effect(() => x)\`` template literal
1381
+ * would get `, { __sourceLocation: ... }` injected INSIDE the string,
1382
+ * corrupting runtime values.
1383
+ *
1384
+ * Handles:
1385
+ * - `"..."` / `'...'` strings (escape-aware)
1386
+ * - `` `...` `` template literals; interpolations `${...}` are KEPT as
1387
+ * code (their content can contain real `signal()` calls worth catching)
1388
+ * - `// ...` line comments
1389
+ * - `/* ... *\/` block comments
1390
+ *
1391
+ * Regex literals (`/foo/g`) are NOT special-cased — they're rare and the
1392
+ * downstream extractBalancedArgs handles unmatched parens by returning null.
1393
+ *
1394
+ * @internal — exported for tests.
1395
+ */
1396
+ export function _maskStringsAndComments(code: string): string {
1397
+ const out: string[] = []
1398
+ let i = 0
1399
+ const n = code.length
1400
+ while (i < n) {
1401
+ const c = code[i]
1402
+ const c1 = code[i + 1]
1403
+
1404
+ // Line comment `// ...`
1405
+ if (c === '/' && c1 === '/') {
1406
+ while (i < n && code[i] !== '\n') {
1407
+ out.push(' ')
1408
+ i++
1409
+ }
1410
+ continue
1411
+ }
1412
+ // Block comment `/* ... */`
1413
+ if (c === '/' && c1 === '*') {
1414
+ out.push(' ', ' ')
1415
+ i += 2
1416
+ while (i < n) {
1417
+ if (code[i] === '*' && code[i + 1] === '/') {
1418
+ out.push(' ', ' ')
1419
+ i += 2
1420
+ break
1421
+ }
1422
+ // Preserve newlines so line numbers don't shift
1423
+ out.push(code[i] === '\n' ? '\n' : ' ')
1424
+ i++
1425
+ }
1426
+ continue
1427
+ }
1428
+ // String literal "..." or '...'
1429
+ if (c === '"' || c === "'") {
1430
+ const quote = c
1431
+ out.push(' ')
1432
+ i++
1433
+ while (i < n && code[i] !== quote) {
1434
+ // Escape sequence — skip the next char too (handles `\"`, `\\`, etc.)
1435
+ if (code[i] === '\\' && i + 1 < n) {
1436
+ // Preserve a newline (line-continuation `\<LF>`) as a newline.
1437
+ out.push(' ', code[i + 1] === '\n' ? '\n' : ' ')
1438
+ i += 2
1439
+ continue
1440
+ }
1441
+ // Unterminated string (legacy parsers stop at newline) — break
1442
+ if (code[i] === '\n') break
1443
+ out.push(' ')
1444
+ i++
1445
+ }
1446
+ if (i < n && code[i] === quote) {
1447
+ out.push(' ')
1448
+ i++
1449
+ }
1450
+ continue
1451
+ }
1452
+ // Template literal `...` — preserve `${...}` interpolations as code
1453
+ if (c === '`') {
1454
+ out.push(' ')
1455
+ i++
1456
+ while (i < n && code[i] !== '`') {
1457
+ if (code[i] === '\\' && i + 1 < n) {
1458
+ out.push(' ', code[i + 1] === '\n' ? '\n' : ' ')
1459
+ i += 2
1460
+ continue
1461
+ }
1462
+ // `${...}` — keep the interpolation body as code (with nested
1463
+ // brace tracking so we find the matching `}`).
1464
+ if (code[i] === '$' && code[i + 1] === '{') {
1465
+ out.push(' ', ' ')
1466
+ i += 2
1467
+ let depth = 1
1468
+ while (i < n && depth > 0) {
1469
+ if (code[i] === '{') {
1470
+ depth++
1471
+ out.push(code[i] ?? ' ')
1472
+ i++
1473
+ continue
1474
+ }
1475
+ if (code[i] === '}') {
1476
+ depth--
1477
+ if (depth === 0) {
1478
+ out.push(' ')
1479
+ i++
1480
+ break
1481
+ }
1482
+ out.push(code[i] ?? ' ')
1483
+ i++
1484
+ continue
1485
+ }
1486
+ // Inside `${}` — pass through as code (might contain `signal(` etc).
1487
+ out.push(code[i] ?? ' ')
1488
+ i++
1489
+ }
1490
+ continue
1491
+ }
1492
+ // Preserve newlines so line numbers don't shift.
1493
+ out.push(code[i] === '\n' ? '\n' : ' ')
1494
+ i++
1495
+ }
1496
+ if (i < n && code[i] === '`') {
1497
+ out.push(' ')
1498
+ i++
1499
+ }
1500
+ continue
1501
+ }
1502
+ out.push(c ?? '')
1503
+ i++
1504
+ }
1505
+ return out.join('')
1506
+ }
1507
+
1508
+ /**
1509
+ * Compute the 0-indexed character offset for the start of each line.
1510
+ * `lineStarts[i]` is the offset of the FIRST character on line i+1
1511
+ * (1-based, so `lineStarts[0]` = offset 0 = line 1).
1512
+ *
1513
+ * @internal — exported for tests.
1514
+ */
1515
+ export function _computeLineStarts(code: string): number[] {
1516
+ const starts: number[] = [0]
1517
+ for (let i = 0; i < code.length; i++) {
1518
+ if (code.charCodeAt(i) === 10) starts.push(i + 1) // \n
1519
+ }
1520
+ return starts
1521
+ }
1522
+
1523
+ /**
1524
+ * Convert a 0-indexed offset to `{ line: 1-based, col: 1-based }` using a
1525
+ * pre-computed line-starts array. Binary search → O(log N) per lookup.
1526
+ *
1527
+ * @internal — exported for tests.
1528
+ */
1529
+ export function _offsetToLineCol(
1530
+ offset: number,
1531
+ lineStarts: number[],
1532
+ ): { line: number; col: number } {
1533
+ // Binary search for the largest lineStarts[i] <= offset.
1534
+ let lo = 0
1535
+ let hi = lineStarts.length - 1
1536
+ while (lo < hi) {
1537
+ const mid = (lo + hi + 1) >>> 1
1538
+ const v = lineStarts[mid]
1539
+ if (v !== undefined && v <= offset) lo = mid
1540
+ else hi = mid - 1
1541
+ }
1542
+ const lineStart = lineStarts[lo] ?? 0
1543
+ return { line: lo + 1, col: offset - lineStart + 1 }
1544
+ }
1545
+
1015
1546
  function injectHmr(code: string, moduleId: string): string {
1016
1547
  const hasSignals = SIGNAL_PREFIX_RE.test(code)
1017
1548
  SIGNAL_PREFIX_RE.lastIndex = 0
@@ -54,7 +54,11 @@ afterEach(() => {
54
54
  })
55
55
 
56
56
  function bootstrap(opts?: PyreonPluginOptions) {
57
- const plugin = pyreonPlugin(opts)
57
+ // Default `lpih: false` — these tests cover the SSR / watcher / debounce
58
+ // surface; LPIH auto-bridge adds its own middleware whose presence would
59
+ // change the `middlewares.use` call count + first-element shape. Tests
60
+ // that specifically exercise LPIH live in `lpih-auto-bridge.test.ts`.
61
+ const plugin = pyreonPlugin({ lpih: false, ...opts })
58
62
  ;(plugin.config as unknown as ConfigHook)({ root }, { command: 'serve' })
59
63
  return plugin
60
64
  }