sygnal 5.3.7 → 5.4.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.
Files changed (226) hide show
  1. package/README.md +53 -38
  2. package/dist/astro/client.cjs.js +18 -8106
  3. package/dist/astro/client.cjs.js.map +1 -0
  4. package/dist/astro/client.mjs +17 -8105
  5. package/dist/astro/client.mjs.map +1 -0
  6. package/dist/astro/index.cjs.js +768 -9
  7. package/dist/astro/index.cjs.js.map +1 -0
  8. package/dist/astro/index.mjs +768 -9
  9. package/dist/astro/index.mjs.map +1 -0
  10. package/dist/astro/server.cjs.js +4 -1
  11. package/dist/astro/server.cjs.js.map +1 -0
  12. package/dist/astro/server.mjs +4 -1
  13. package/dist/astro/server.mjs.map +1 -0
  14. package/dist/diagnostics.cjs.js +1297 -0
  15. package/dist/diagnostics.cjs.js.map +1 -0
  16. package/dist/diagnostics.esm.js +1267 -0
  17. package/dist/diagnostics.esm.js.map +1 -0
  18. package/dist/index.cjs.js +2981 -1386
  19. package/dist/index.cjs.js.map +1 -0
  20. package/dist/index.d.ts +848 -111
  21. package/dist/index.esm.js +2997 -1411
  22. package/dist/index.esm.js.map +1 -0
  23. package/dist/jsx-dev-runtime.cjs.js +15 -9
  24. package/dist/jsx-dev-runtime.cjs.js.map +1 -0
  25. package/dist/jsx-dev-runtime.esm.js +15 -9
  26. package/dist/jsx-dev-runtime.esm.js.map +1 -0
  27. package/dist/jsx-runtime.cjs.js +15 -9
  28. package/dist/jsx-runtime.cjs.js.map +1 -0
  29. package/dist/jsx-runtime.esm.js +15 -9
  30. package/dist/jsx-runtime.esm.js.map +1 -0
  31. package/dist/jsx.cjs.js +15 -9
  32. package/dist/jsx.cjs.js.map +1 -0
  33. package/dist/jsx.esm.js +15 -9
  34. package/dist/jsx.esm.js.map +1 -0
  35. package/dist/sygnal.min.js +2 -1
  36. package/dist/sygnal.min.js.map +1 -0
  37. package/dist/vike/+config.cjs.js +1 -0
  38. package/dist/vike/+config.cjs.js.map +1 -0
  39. package/dist/vike/+config.js +1 -0
  40. package/dist/vike/+config.js.map +1 -0
  41. package/dist/vike/ClientOnly.cjs.js +1 -0
  42. package/dist/vike/ClientOnly.cjs.js.map +1 -0
  43. package/dist/vike/ClientOnly.mjs +1 -0
  44. package/dist/vike/ClientOnly.mjs.map +1 -0
  45. package/dist/vike/onRenderClient.cjs.js +1 -0
  46. package/dist/vike/onRenderClient.cjs.js.map +1 -0
  47. package/dist/vike/onRenderClient.mjs +1 -0
  48. package/dist/vike/onRenderClient.mjs.map +1 -0
  49. package/dist/vike/onRenderHtml.cjs.js +1 -0
  50. package/dist/vike/onRenderHtml.cjs.js.map +1 -0
  51. package/dist/vike/onRenderHtml.mjs +1 -0
  52. package/dist/vike/onRenderHtml.mjs.map +1 -0
  53. package/dist/vite/plugin.cjs.js +670 -44
  54. package/dist/vite/plugin.cjs.js.map +1 -0
  55. package/dist/vite/plugin.mjs +670 -44
  56. package/dist/vite/plugin.mjs.map +1 -0
  57. package/llms.txt +250 -0
  58. package/package.json +15 -6
  59. package/src/astro/client.ts +13 -2
  60. package/src/astro/index.d.ts +24 -1
  61. package/src/astro/index.ts +40 -9
  62. package/src/astro/server.ts +3 -1
  63. package/src/collection.ts +5 -2
  64. package/src/component.ts +375 -330
  65. package/src/cycle/dom/MainDOMSource.ts +4 -0
  66. package/src/cycle/dom/classNameModule.ts +74 -0
  67. package/src/cycle/dom/controlledInputModule.ts +33 -0
  68. package/src/cycle/dom/enrichEventStream.ts +9 -3
  69. package/src/cycle/dom/makeDOMDriver.ts +1 -2
  70. package/src/cycle/dom/mockDOMSource.ts +62 -8
  71. package/src/cycle/dom/modules.ts +5 -1
  72. package/src/cycle/state/Collection.ts +13 -3
  73. package/src/cycle/state/StateSource.ts +1 -1
  74. package/src/cycle/state/pickCombine.ts +35 -1
  75. package/src/cycle/state/withState.ts +1 -1
  76. package/src/extra/devtools.ts +8 -0
  77. package/src/extra/diagnostics/checks/collections.ts +67 -0
  78. package/src/extra/diagnostics/checks/dom.ts +241 -0
  79. package/src/extra/diagnostics/checks/events.ts +68 -0
  80. package/src/extra/diagnostics/checks/index.ts +109 -0
  81. package/src/extra/diagnostics/checks/inspect.ts +319 -0
  82. package/src/extra/diagnostics/checks/props.ts +51 -0
  83. package/src/extra/diagnostics/checks/public.d.ts +211 -0
  84. package/src/extra/diagnostics/checks/rxjsHints.ts +88 -0
  85. package/src/extra/diagnostics/checks/shared.ts +119 -0
  86. package/src/extra/diagnostics/checks/state.ts +55 -0
  87. package/src/extra/diagnostics/checks/strict.ts +112 -0
  88. package/src/extra/diagnostics/checks/wiring.ts +65 -0
  89. package/src/extra/diagnostics/codes.ts +232 -0
  90. package/src/extra/diagnostics/index.ts +328 -0
  91. package/src/extra/diagnostics/legacy.ts +59 -0
  92. package/src/extra/driverFactories.ts +106 -58
  93. package/src/extra/eventDriver.ts +4 -0
  94. package/src/extra/flatten.ts +75 -0
  95. package/src/extra/reducers.ts +12 -5
  96. package/src/extra/run.ts +11 -0
  97. package/src/extra/testing.ts +890 -180
  98. package/src/extra/xstreamExtras.ts +269 -0
  99. package/src/index.d.ts +428 -17
  100. package/src/index.ts +7 -6
  101. package/src/pragma/index.ts +14 -9
  102. package/src/switchable.ts +10 -8
  103. package/src/vike/types.ts +21 -4
  104. package/src/vite/plugin.d.ts +95 -2
  105. package/src/vite/plugin.ts +720 -54
  106. package/dist/astro/astro/client.d.ts +0 -5
  107. package/dist/astro/astro/index.d.ts +0 -16
  108. package/dist/astro/astro/server.d.ts +0 -12
  109. package/dist/astro/client.d.ts +0 -5
  110. package/dist/astro/client.esm.js +0 -4665
  111. package/dist/astro/collection.d.ts +0 -12
  112. package/dist/astro/component.d.ts +0 -22
  113. package/dist/astro/cycle/dom/BodyDOMSource.d.ts +0 -10
  114. package/dist/astro/cycle/dom/DOMSource.d.ts +0 -11
  115. package/dist/astro/cycle/dom/DocumentDOMSource.d.ts +0 -12
  116. package/dist/astro/cycle/dom/ElementFinder.d.ts +0 -8
  117. package/dist/astro/cycle/dom/EventDelegator.d.ts +0 -34
  118. package/dist/astro/cycle/dom/IsolateModule.d.ts +0 -23
  119. package/dist/astro/cycle/dom/MainDOMSource.d.ts +0 -32
  120. package/dist/astro/cycle/dom/PriorityQueue.d.ts +0 -7
  121. package/dist/astro/cycle/dom/ScopeChecker.d.ts +0 -9
  122. package/dist/astro/cycle/dom/SymbolTree.d.ts +0 -9
  123. package/dist/astro/cycle/dom/VNodeWrapper.d.ts +0 -8
  124. package/dist/astro/cycle/dom/enrichEventStream.d.ts +0 -24
  125. package/dist/astro/cycle/dom/fromEvent.d.ts +0 -6
  126. package/dist/astro/cycle/dom/index.d.ts +0 -8
  127. package/dist/astro/cycle/dom/isolate.d.ts +0 -9
  128. package/dist/astro/cycle/dom/makeDOMDriver.d.ts +0 -11
  129. package/dist/astro/cycle/dom/mockDOMSource.d.ts +0 -17
  130. package/dist/astro/cycle/dom/modules.d.ts +0 -5
  131. package/dist/astro/cycle/dom/snabbdom.d.ts +0 -22
  132. package/dist/astro/cycle/dom/styleModule.d.ts +0 -13
  133. package/dist/astro/cycle/dom/thunk.d.ts +0 -11
  134. package/dist/astro/cycle/dom/utils.d.ts +0 -8
  135. package/dist/astro/cycle/isolate/index.d.ts +0 -42
  136. package/dist/astro/cycle/run/adapt.d.ts +0 -6
  137. package/dist/astro/cycle/run/index.d.ts +0 -36
  138. package/dist/astro/cycle/run/internals.d.ts +0 -8
  139. package/dist/astro/cycle/run/types.d.ts +0 -49
  140. package/dist/astro/cycle/state/Collection.d.ts +0 -29
  141. package/dist/astro/cycle/state/StateSource.d.ts +0 -20
  142. package/dist/astro/cycle/state/index.d.ts +0 -5
  143. package/dist/astro/cycle/state/pickCombine.d.ts +0 -3
  144. package/dist/astro/cycle/state/pickMerge.d.ts +0 -3
  145. package/dist/astro/cycle/state/types.d.ts +0 -18
  146. package/dist/astro/cycle/state/withState.d.ts +0 -17
  147. package/dist/astro/extra/classes.d.ts +0 -7
  148. package/dist/astro/extra/devtools.d.ts +0 -78
  149. package/dist/astro/extra/dragDriver.d.ts +0 -20
  150. package/dist/astro/extra/driverFactories.d.ts +0 -12
  151. package/dist/astro/extra/eventDriver.d.ts +0 -9
  152. package/dist/astro/extra/exactState.d.ts +0 -1
  153. package/dist/astro/extra/hmr.d.ts +0 -17
  154. package/dist/astro/extra/logDriver.d.ts +0 -2
  155. package/dist/astro/extra/processDrag.d.ts +0 -19
  156. package/dist/astro/extra/processForm.d.ts +0 -15
  157. package/dist/astro/extra/run.d.ts +0 -13
  158. package/dist/astro/extra/xstreamCompat.d.ts +0 -5
  159. package/dist/astro/index.d.ts +0 -19
  160. package/dist/astro/index.esm.js +0 -27
  161. package/dist/astro/jsx-dev-runtime.d.ts +0 -1
  162. package/dist/astro/jsx-runtime.d.ts +0 -4
  163. package/dist/astro/jsx.d.ts +0 -2
  164. package/dist/astro/pragma/fn.d.ts +0 -7
  165. package/dist/astro/pragma/index.d.ts +0 -7
  166. package/dist/astro/pragma/is.d.ts +0 -10
  167. package/dist/astro/server.d.ts +0 -12
  168. package/dist/astro/server.esm.js +0 -25
  169. package/dist/astro/switchable.d.ts +0 -7
  170. package/dist/collection.d.ts +0 -12
  171. package/dist/component.d.ts +0 -22
  172. package/dist/cycle/dom/BodyDOMSource.d.ts +0 -10
  173. package/dist/cycle/dom/DOMSource.d.ts +0 -11
  174. package/dist/cycle/dom/DocumentDOMSource.d.ts +0 -12
  175. package/dist/cycle/dom/ElementFinder.d.ts +0 -8
  176. package/dist/cycle/dom/EventDelegator.d.ts +0 -34
  177. package/dist/cycle/dom/IsolateModule.d.ts +0 -23
  178. package/dist/cycle/dom/MainDOMSource.d.ts +0 -32
  179. package/dist/cycle/dom/PriorityQueue.d.ts +0 -7
  180. package/dist/cycle/dom/ScopeChecker.d.ts +0 -9
  181. package/dist/cycle/dom/SymbolTree.d.ts +0 -9
  182. package/dist/cycle/dom/VNodeWrapper.d.ts +0 -8
  183. package/dist/cycle/dom/enrichEventStream.d.ts +0 -24
  184. package/dist/cycle/dom/fromEvent.d.ts +0 -6
  185. package/dist/cycle/dom/index.d.ts +0 -8
  186. package/dist/cycle/dom/isolate.d.ts +0 -9
  187. package/dist/cycle/dom/makeDOMDriver.d.ts +0 -11
  188. package/dist/cycle/dom/mockDOMSource.d.ts +0 -17
  189. package/dist/cycle/dom/modules.d.ts +0 -5
  190. package/dist/cycle/dom/snabbdom.d.ts +0 -22
  191. package/dist/cycle/dom/styleModule.d.ts +0 -13
  192. package/dist/cycle/dom/thunk.d.ts +0 -11
  193. package/dist/cycle/dom/utils.d.ts +0 -8
  194. package/dist/cycle/isolate/index.d.ts +0 -42
  195. package/dist/cycle/run/adapt.d.ts +0 -6
  196. package/dist/cycle/run/index.d.ts +0 -36
  197. package/dist/cycle/run/internals.d.ts +0 -8
  198. package/dist/cycle/run/types.d.ts +0 -49
  199. package/dist/cycle/state/Collection.d.ts +0 -29
  200. package/dist/cycle/state/StateSource.d.ts +0 -20
  201. package/dist/cycle/state/index.d.ts +0 -5
  202. package/dist/cycle/state/pickCombine.d.ts +0 -3
  203. package/dist/cycle/state/pickMerge.d.ts +0 -3
  204. package/dist/cycle/state/types.d.ts +0 -18
  205. package/dist/cycle/state/withState.d.ts +0 -17
  206. package/dist/extra/classes.d.ts +0 -7
  207. package/dist/extra/devtools.d.ts +0 -78
  208. package/dist/extra/dragDriver.d.ts +0 -20
  209. package/dist/extra/driverFactories.d.ts +0 -12
  210. package/dist/extra/eventDriver.d.ts +0 -9
  211. package/dist/extra/exactState.d.ts +0 -1
  212. package/dist/extra/hmr.d.ts +0 -17
  213. package/dist/extra/logDriver.d.ts +0 -2
  214. package/dist/extra/processDrag.d.ts +0 -19
  215. package/dist/extra/processForm.d.ts +0 -15
  216. package/dist/extra/run.d.ts +0 -13
  217. package/dist/extra/xstreamCompat.d.ts +0 -5
  218. package/dist/jsx-dev-runtime.d.ts +0 -1
  219. package/dist/jsx-dev-runtime.js +0 -224
  220. package/dist/jsx-runtime.d.ts +0 -4
  221. package/dist/jsx-runtime.js +0 -224
  222. package/dist/jsx.d.ts +0 -2
  223. package/dist/pragma/fn.d.ts +0 -7
  224. package/dist/pragma/index.d.ts +0 -7
  225. package/dist/pragma/is.d.ts +0 -10
  226. package/dist/switchable.d.ts +0 -7
@@ -1,15 +1,92 @@
1
1
  /**
2
2
  * Sygnal Vite Plugin
3
3
  *
4
- * Auto-configures JSX and wires up HMR with state preservation.
4
+ * Auto-configures JSX, wires up HMR with state preservation and, in dev,
5
+ * turns on Sygnal's diagnostics (runtime checks and the static checker).
6
+ * Nothing it adds in dev reaches a production build.
5
7
  *
6
8
  * Usage:
7
9
  * import sygnal from 'sygnal/vite'
8
10
  * export default defineConfig({ plugins: [sygnal()] })
9
11
  *
10
12
  * What it does:
11
- * 1. Configures OXC for automatic JSX transform with sygnal as the import source
13
+ * 1. Configures the automatic JSX transform with sygnal as the import source:
14
+ * `oxc` (Vite 8), plus `esbuild` under Vite 7 and older
12
15
  * 2. Detects files that call `run()` from sygnal and auto-injects HMR wiring
16
+ * 3. Dev mode (`vite` / `vite dev` only; never `vite build`). Into every
17
+ * file that imports `run` from sygnal (including entries with manual HMR
18
+ * wiring or `disableHmr`) it inserts, on an existing line:
19
+ *
20
+ * if (globalThis.__SYGNAL_DEV__ === undefined) globalThis.__SYGNAL_DEV__ = true;
21
+ * import 'sygnal/diagnostics';import 'virtual:sygnal/dev';
22
+ *
23
+ * The flag turns on runtime diagnostics in 'warn' mode; 'sygnal/diagnostics'
24
+ * registers the runtime checks (SYG101-106, 201, 202, 301, 401, ...);
25
+ * 'virtual:sygnal/dev' logs static-check results in the browser console
26
+ * (see 5). With `diagnostics: { strict: true }` the snippet also sets
27
+ * `globalThis.__SYGNAL_STRICT__ = true` (same guard), which turns on the
28
+ * strict (SYG5xx) runtime checks of 'sygnal/diagnostics'. The snippet goes
29
+ * after any shebang line and directive prologue ('use strict',
30
+ * 'use client'); line numbers are unchanged and the transform returns a
31
+ * sourcemap. Opt out per app with `run(App, drivers, { diagnostics: 'off' })`
32
+ * or for the whole dev server with `sygnal({ diagnostics: 'off' })`.
33
+ * 4. `diagnostics` option. The runtime core only understands
34
+ * `__SYGNAL_DEV__ === true` ('warn'), so another mode or an ignore list
35
+ * is passed as run()'s own `diagnostics` option: imports of 'sygnal'
36
+ * from the entry files above resolve to a dev-only wrapper module
37
+ * ('\0sygnal-dev:runtime') that re-exports sygnal with a run() that adds
38
+ * `diagnostics: { mode, ignore }` unless the call sets `diagnostics`
39
+ * itself. With the default ('warn', no ignore list) there is no wrapper.
40
+ * 5. `check` option: runs sygnal-check (an optional dependency, loaded
41
+ * lazily from the project; `strict` defaults to `diagnostics.strict`)
42
+ * over `include` (default: the existing ones of src/, pages/ and
43
+ * renderer/, else the project root, with a notice) when the dev
44
+ * server starts and again after every source file change. Results go to
45
+ * the terminal in sygnal-check's format, and to the browser as a
46
+ * 'sygnal:check' HMR event that the dev client ('virtual:sygnal/dev')
47
+ * logs with console.warn (non-disruptive). The dev client asks for the
48
+ * current findings when the page loads ('sygnal:check:request'), and only
49
+ * that client is answered; after a source change the new findings go to
50
+ * every connected client, and only when they changed. Error-severity
51
+ * findings also open Vite's error overlay (`overlay: false`: never).
52
+ * sygnal-check's codes are currently all warnings or info, so in practice
53
+ * everything goes to the console. Warnings never use the overlay: while
54
+ * one is open Vite's client reloads the page on the next HMR update
55
+ * (`overlay: 'warn'` is treated as 'error', with a notice). The dev
56
+ * client closes a sygnal-check overlay before each update, and the
57
+ * server sends it again after the re-check, so it never causes a reload
58
+ * and doesn't go stale. A missing sygnal-check is skipped silently; a
59
+ * checker that throws only logs a warning.
60
+ * 6. Vitest (process.env.VITEST): appends the package's 'sygnal/diagnostics'
61
+ * ESM file to `test.setupFiles` (merged with a string or array value;
62
+ * not added twice), so renderComponent tests get the runtime checks.
63
+ * Its directory is added to the resolved `server.fs.allow` (G-050):
64
+ * in jsdom / happy-dom environments Vitest loads setup files through
65
+ * Vite's /@fs/ URLs, which a linked sygnal outside the workspace would
66
+ * otherwise fail. Opt out with `vitestSetup: false`. Under Vitest the transform adds
67
+ * nothing (no flags, HMR wiring, run() wrapper, dev client or checker):
68
+ * a __SYGNAL_DEV__ / __SYGNAL_STRICT__ flag set by one test file would
69
+ * leak into later test files of the same worker, and renderComponent()
70
+ * manages the diagnostics modes itself.
71
+ * 7. Vike and Astro (G-014): the app is started by sygnal's own client
72
+ * entry, which user code doesn't import. Client-side imports of
73
+ * 'sygnal/vike/onRenderClient' resolve to a dev wrapper that sets the
74
+ * flag and loads the checks first. Astro resolves its renderer entry on
75
+ * the server and the browser loads the file itself, so the same snippet
76
+ * is added to sygnal's 'astro/client' file by the transform. That file
77
+ * imports the public 'sygnal' entry (one shared core, B-019), so its
78
+ * 'sygnal' import gets the run() wrapper too: every `diagnostics` mode
79
+ * and ignore list works for islands. The sygnal/astro integration adds
80
+ * this plugin in `astro dev`.
81
+ *
82
+ * Why not Vite's `define`? Vite's dependency optimizer does not apply user
83
+ * `define` replacements to pre-bundled dependencies (only process.env.NODE_ENV),
84
+ * so a `define` would never reach sygnal's own code when sygnal is pre-bundled.
85
+ * Vite's client does copy `define` entries onto globalThis at runtime, but only
86
+ * when `/@vite/client` is loaded (not under SSR or Vitest, where it would also
87
+ * switch diagnostics on for every test run). Injecting the flag into the
88
+ * entry file that calls run() is deterministic and scoped to the dev app; it
89
+ * runs before run() is called, which is when the diagnostics mode is resolved.
13
90
  *
14
91
  * The HMR transform finds the pattern:
15
92
  * import { run } from 'sygnal'
@@ -21,7 +98,56 @@
21
98
  * import.meta.hot.accept('./App.jsx', __sygnal.hmr)
22
99
  * import.meta.hot.dispose(__sygnal.dispose)
23
100
  * }
101
+ *
102
+ * HMR wiring is only added for a recognized top-level (column 0) run()
103
+ * statement whose result reference is actually defined (see RUN_CALL_RE). It
104
+ * is skipped — the dev flag is still injected — for any other shape (e.g.
105
+ * `app = run(App)`, nested or indented calls), for test files (*.test.*,
106
+ * *.spec.*) and under Vitest (process.env.VITEST).
24
107
  */
108
+ // Node built-ins (the plugin runs in Node). The package has no @types/node.
109
+ // @ts-ignore
110
+ import fs from 'node:fs'
111
+ // @ts-ignore
112
+ import path from 'node:path'
113
+ // @ts-ignore
114
+ import { createRequire } from 'node:module'
115
+ // @ts-ignore
116
+ import { pathToFileURL } from 'node:url'
117
+
118
+ const nodeProcess: any = (globalThis as any).process
119
+
120
+ export type DiagnosticsMode = 'off' | 'collect' | 'warn' | 'error'
121
+
122
+ export interface DiagnosticsPluginOptions {
123
+ /** Runtime diagnostics mode in dev. @default 'warn' */
124
+ mode?: DiagnosticsMode
125
+ /** Strict (canonical-form, SYG5xx) runtime checks: sets globalThis.__SYGNAL_STRICT__. @default false */
126
+ strict?: boolean
127
+ /** Diagnostic codes to drop, e.g. ['SYG105'] */
128
+ ignore?: string[]
129
+ }
130
+
131
+ export interface CheckPluginOptions {
132
+ /** Also run sygnal-check's strict (SYG5xx) rules. @default `diagnostics.strict` */
133
+ strict?: boolean
134
+ /**
135
+ * Files, directories or globs to check, relative to the Vite root.
136
+ * @default the existing ones of ['src', 'pages', 'renderer'], else the project root
137
+ */
138
+ include?: string[]
139
+ /** Codes to drop. @default the `diagnostics` ignore list */
140
+ ignore?: string[]
141
+ /**
142
+ * Error-severity findings also open Vite's error overlay ('error', the
143
+ * default); false: never. All findings are logged in the terminal and the
144
+ * browser console. Warnings never use the overlay (Vite reloads the page on
145
+ * the next update while one is open): 'warn' is accepted but treated as
146
+ * 'error'. sygnal-check currently reports no error-severity codes.
147
+ * @default 'error'
148
+ */
149
+ overlay?: 'error' | 'warn' | false
150
+ }
25
151
 
26
152
  export interface SygnalPluginOptions {
27
153
  /**
@@ -37,19 +163,69 @@ export interface SygnalPluginOptions {
37
163
  * @default false
38
164
  */
39
165
  disableHmr?: boolean
166
+
167
+ /**
168
+ * Runtime diagnostics in dev (`vite`, not `vite build`): a mode, or
169
+ * { mode, strict, ignore }. 'off' injects nothing. A `diagnostics` option
170
+ * passed to run() itself still wins.
171
+ * @default 'warn'
172
+ */
173
+ diagnostics?: DiagnosticsMode | DiagnosticsPluginOptions
174
+
175
+ /**
176
+ * Run sygnal-check in dev on startup and on file changes. Skipped silently
177
+ * when sygnal-check isn't installed.
178
+ * @default true
179
+ */
180
+ check?: boolean | CheckPluginOptions
181
+
182
+ /**
183
+ * Under Vitest, add 'sygnal/diagnostics' to `test.setupFiles` so tests get
184
+ * the runtime checks.
185
+ * @default true
186
+ */
187
+ vitestSetup?: boolean
40
188
  }
41
189
 
190
+ // Virtual modules (dev server only)
191
+ const DEV_CLIENT = 'virtual:sygnal/dev'
192
+ const DEV_CLIENT_ID = '\0' + DEV_CLIENT
193
+ const RUNTIME_ID = '\0sygnal-dev:runtime'
194
+ const VIKE_CLIENT = 'sygnal/vike/onRenderClient'
195
+ const VIKE_CLIENT_ID = '\0sygnal-dev:vike-client'
196
+ const CHECK_EVENT = 'sygnal:check'
197
+ // Sent by the dev client when it loads: the server answers that client only
198
+ const CHECK_REQUEST = 'sygnal:check:request'
199
+ const OVERLAY_PLUGIN = 'sygnal-check'
200
+ // Register the runtime checks with the core that loaded last (see the Vike wrapper)
201
+ const REINSTALL_IMPORT = `import { installChecks as __sygnalInstallChecks } from 'sygnal/diagnostics';`
202
+ const REINSTALL_CALL = 'try { __sygnalInstallChecks() } catch (e) { console.warn(e) }\n'
203
+
42
204
  export default function sygnal(options: SygnalPluginOptions = {}) {
43
- const { disableJsx = false, disableHmr = false } = options
205
+ const { disableJsx = false, disableHmr = false, vitestSetup = true } = options
206
+ const diagnostics = normalizeDiagnostics(options.diagnostics)
207
+ const devOn = diagnostics.mode !== 'off'
208
+ // A mode other than the flag's 'warn', or an ignore list, needs run()'s option
209
+ const wrapRun = devOn && (diagnostics.mode !== 'warn' || diagnostics.ignore.length > 0)
44
210
  let isServe = false
211
+ let isVitest = false
212
+ let root: string = nodeProcess.cwd()
213
+ // Files whose `import ... from 'sygnal'` resolves to the run() wrapper
214
+ const runtimeImporters = new Set<string>()
215
+ // Statements run before the app starts: dev flag (+ strict flag)
216
+ const flags = DEV_FLAG + (diagnostics.strict ? STRICT_FLAG : '')
217
+ const devImports = `import 'sygnal/diagnostics';import '${DEV_CLIENT}';`
218
+ let astroClient: Set<string> | undefined
45
219
 
46
220
  return {
47
221
  name: 'vite-plugin-sygnal',
48
222
 
49
- config(_config: any, env: { command: string }) {
223
+ config(this: any, config: any, env: { command: string }) {
50
224
  isServe = env.command === 'serve'
225
+ isVitest = !!nodeProcess?.env?.VITEST
226
+ root = config?.root ? path.resolve(config.root) : nodeProcess.cwd()
51
227
 
52
- const config: any = {
228
+ const result: any = {
53
229
  // Ensure sygnal is bundled for SSR rather than externalized.
54
230
  // Without this, Node fails to resolve sygnal's exports from
55
231
  // Vike's server chunks in production builds.
@@ -59,94 +235,584 @@ export default function sygnal(options: SygnalPluginOptions = {}) {
59
235
  }
60
236
 
61
237
  if (!disableJsx) {
62
- config.oxc = {
238
+ result.oxc = {
63
239
  jsx: {
64
240
  runtime: 'automatic' as const,
65
241
  importSource: 'sygnal',
66
242
  },
67
243
  }
244
+ // Vite 7 and older (Astro 6, Vitest on Vite 7) compile JSX with
245
+ // esbuild and ignore `oxc` (B-027); Vite 8 warns about `esbuild`
246
+ // from a plugin, so it is only set below Vite 8.
247
+ if (viteMajor(this, root) < 8) {
248
+ result.esbuild = { jsx: 'automatic', jsxImportSource: 'sygnal' }
249
+ }
250
+ // Vite 8's dependency scanner doesn't use the `oxc` options: without
251
+ // this it compiles JSX for React, fails to resolve
252
+ // react/jsx-dev-runtime and skips pre-bundling (a reload on first load).
253
+ if (isServe && !isVitest) {
254
+ result.optimizeDeps = { rolldownOptions: { transform: { jsx: { runtime: 'automatic', importSource: 'sygnal' } } } }
255
+ }
68
256
  }
69
257
 
70
- return config
258
+ if (isVitest) {
259
+ if (vitestSetup) {
260
+ const setup = diagnosticsSetupFile(root)
261
+ const existing = ([] as string[]).concat(config?.test?.setupFiles || [])
262
+ const loaded = existing.some(f => typeof f === 'string' &&
263
+ (f === 'sygnal/diagnostics' || (!!setup && path.resolve(root, f) === setup)))
264
+ // Vite merges arrays (and a string with an array) by concatenation
265
+ if (setup && !loaded) result.test = { setupFiles: [setup] }
266
+ }
267
+ } else if (isServe && devOn && sygnalInNodeModules(root)) {
268
+ // The dependency scan reads the original sources, which don't import
269
+ // the checks entry: pre-bundle it with sygnal so the first page load
270
+ // doesn't trigger a re-optimization. Only for an installed sygnal: a
271
+ // linked one is served from source, and pre-bundling the checks alone
272
+ // would give them their own copy of the core.
273
+ result.optimizeDeps = { ...result.optimizeDeps, include: ['sygnal/diagnostics'] }
274
+ }
275
+
276
+ return result
277
+ },
278
+
279
+ configResolved(config: any) {
280
+ if (config?.root) root = config.root
281
+ // G-050: Vitest loads setup files by URL (/@fs/...) in browser-like
282
+ // environments (jsdom, happy-dom), and Vite refuses files outside
283
+ // server.fs.allow, which is the project's workspace by default. A linked
284
+ // (or hoisted) sygnal lives outside it, so allow its setup file's
285
+ // directory. The resolved list already holds Vite's defaults.
286
+ if (isVitest) {
287
+ const setup = diagnosticsSetupFile(root)
288
+ const allow = config?.server?.fs?.allow
289
+ if (setup && Array.isArray(allow)) {
290
+ const dir = path.dirname(setup)
291
+ const inside = (base: string) => {
292
+ const rel = path.relative(path.resolve(base), dir)
293
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
294
+ }
295
+ if (!allow.some(inside)) allow.push(dir)
296
+ }
297
+ }
71
298
  },
72
299
 
73
- transform(code: string, id: string) {
74
- if (disableHmr) return null
300
+ resolveId: {
301
+ order: 'pre' as const,
302
+ async handler(this: any, source: string, importer: string | undefined, opts: any) {
303
+ if (!isServe || isVitest) return null
304
+ if (source === DEV_CLIENT) return DEV_CLIENT_ID
305
+ if (source === 'sygnal' && importer && wrapRun && runtimeImporters.has(cleanId(importer))) {
306
+ return RUNTIME_ID
307
+ }
308
+ if (source === VIKE_CLIENT && devOn && !opts?.ssr) {
309
+ if (importer !== VIKE_CLIENT_ID) return VIKE_CLIENT_ID
310
+ // The wrapper's own import of the real entry: resolve it normally.
311
+ // The real Vike client's run() then gets the run() wrapper too, when
312
+ // it is served unbundled (a pre-bundled copy stays in 'warn' mode).
313
+ const real = await this.resolve(source, importer, { ...opts, skipSelf: true })
314
+ if (real && wrapRun) runtimeImporters.add(cleanId(real.id))
315
+ return real
316
+ }
317
+ return null
318
+ },
319
+ },
320
+
321
+ load(id: string) {
322
+ if (id === DEV_CLIENT_ID) return devClientModule()
323
+ if (id === RUNTIME_ID) return runtimeModule(diagnostics)
324
+ // The checks register again once the real entry has loaded: a
325
+ // pre-bundled copy of it can carry its own copy of the Sygnal core
326
+ // (e.g. with a linked sygnal), which then holds the diagnostics bridge.
327
+ if (id === VIKE_CLIENT_ID) {
328
+ return `${devImports}${REINSTALL_IMPORT}\nexport * from '${VIKE_CLIENT}';\n${flags}\n${REINSTALL_CALL}`
329
+ }
330
+ return null
331
+ },
332
+
333
+ configureServer(server: any) {
334
+ if (isVitest || options.check === false) return
335
+ const checkOptions = typeof options.check === 'object' && options.check ? options.check : {}
336
+ // Deferred: the dev server doesn't wait for the first check
337
+ const checkDefaults = { strict: diagnostics.strict, ignore: diagnostics.ignore }
338
+ setTimeout(() => {
339
+ startChecker(server, root, checkOptions, checkDefaults, options.check !== undefined)
340
+ .catch(err => server?.config?.logger?.warn?.(`[sygnal] sygnal-check could not start: ${err?.message || err}`))
341
+ }, 0)
342
+ },
343
+
344
+ transform(code: string, id: string, opts?: { ssr?: boolean }) {
75
345
  if (!isServe) return null
76
346
 
347
+ // sygnal/astro/client (Astro islands): flags + checks first. It imports
348
+ // the public 'sygnal' entry (the app's core, B-019), which resolves to
349
+ // the run() wrapper when the diagnostics option needs it.
350
+ if (devOn && !isVitest && !opts?.ssr) {
351
+ astroClient = astroClient || astroClientFiles(root)
352
+ if (astroClient.has(cleanId(id))) {
353
+ if (wrapRun) runtimeImporters.add(cleanId(id))
354
+ return withSourcemap(code, [[0, flags + devImports]], '', id)
355
+ }
356
+ }
357
+
77
358
  // Only transform JS/TS/JSX/TSX files
78
359
  if (!/\.[jt]sx?$/.test(id)) return null
79
360
  // Skip node_modules
80
361
  if (id.includes('node_modules')) return null
81
362
  // Must import run from sygnal
82
363
  if (!code.includes('sygnal')) return null
83
- // Skip if HMR is already manually wired
84
- if (code.includes('import.meta.hot')) return null
364
+
365
+ // Match against the code with comments blanked out (same offsets), so
366
+ // e.g. `// run(App)` or a commented-out import is never matched.
367
+ const noComments = blankOut(code, false)
85
368
 
86
369
  // Find: import { run, ... } from 'sygnal'
87
370
  const runImportRe = /import\s+\{[^}]*\brun\b[^}]*\}\s+from\s+['"]sygnal['"]/
88
- if (!runImportRe.test(code)) return null
371
+ if (!runImportRe.test(noComments)) return null
89
372
 
90
- // Find: run(ComponentName — capture the component identifier
91
- const runCallMatch = code.match(
92
- /(?:(?:const|let|var)\s+(?:\{[^}]*\}|\w+)\s*=\s*)?run\s*\(\s*([A-Z]\w*)/
93
- )
94
- if (!runCallMatch) return null
373
+ // Dev snippet: injected into every file that imports run() — also when
374
+ // HMR wiring below is skipped. Inserted after any shebang line and
375
+ // directive prologue, without adding a line. Nothing under Vitest: a
376
+ // global flag set by one test file would leak dev/strict mode into the
377
+ // later test files of the same worker; there the checks come from
378
+ // test.setupFiles and renderComponent() manages the modes.
379
+ const inserts: Array<[number, string]> = []
380
+ if (devOn && !isVitest) {
381
+ inserts.push(flagInsertion(code, flags + devImports))
382
+ if (wrapRun) runtimeImporters.add(cleanId(id))
383
+ }
384
+ const done = (tail = '') => (inserts.length || tail ? withSourcemap(code, inserts, tail, id) : null)
385
+
386
+ // Skip HMR wiring if disabled, in tests, or already manually wired
387
+ if (disableHmr || isVitest || /\.(test|spec)\.[jt]sx?$/.test(id)) return done()
388
+ if (noComments.includes('import.meta.hot')) return done()
95
389
 
96
- const componentName = runCallMatch[1]
390
+ // Strings and template literals blanked out too: a statement-level match only
391
+ const runCall = RUN_CALL_RE.exec(blankOut(code, true))
392
+ if (!runCall) return done()
393
+ const [, binding, componentName] = runCall
97
394
 
98
395
  // Find the import path for this component
99
396
  // Handles: import App from './App.jsx'
100
397
  // import App from "./App"
101
398
  const componentImportRe = new RegExp(
102
- `import\\s+${componentName}\\s+from\\s+['"]([^'"]+)['"]`
399
+ `import\\s+${componentName.replace(/\$/g, '\\$')}\\s+from\\s+['"]([^'"]+)['"]`
103
400
  )
104
- const componentImportMatch = code.match(componentImportRe)
105
- if (!componentImportMatch) return null
106
-
401
+ const componentImportMatch = noComments.match(componentImportRe)
402
+ if (!componentImportMatch) return done()
107
403
  const componentPath = componentImportMatch[1]
108
404
 
109
- // Determine how to access the run() result
110
- let transformed = code
111
-
112
- // Pattern 1: const { hmr, dispose, ... } = run(App, ...)
113
- const destructureMatch = code.match(
114
- /const\s+\{([^}]*)\}\s*=\s*run\s*\(/
115
- )
116
- if (destructureMatch) {
117
- const bindings = destructureMatch[1]
118
- const hasHmr = /\bhmr\b/.test(bindings)
119
- const hasDispose = /\bdispose\b/.test(bindings)
120
-
121
- if (hasHmr && hasDispose) {
122
- // Already has both bindings, just add hot.accept/dispose
123
- transformed += hmrBlock(componentPath, 'hmr', 'dispose')
124
- return { code: transformed, map: null }
125
- }
405
+ // Pattern 3: bare run(App, ...) — no result captured → const __sygnal = run(
406
+ if (!binding) {
407
+ inserts.push([runCall.index, 'const __sygnal = '])
408
+ return done(hmrBlock(componentPath, '__sygnal.hmr', '__sygnal.dispose'))
126
409
  }
127
410
 
128
411
  // Pattern 2: const app = run(App, ...)
129
- const varMatch = code.match(
130
- /(?:const|let|var)\s+(\w+)\s*=\s*run\s*\(/
131
- )
132
- if (varMatch) {
133
- const varName = varMatch[1]
134
- transformed += hmrBlock(componentPath, `${varName}.hmr`, `${varName}.dispose`)
135
- return { code: transformed, map: null }
412
+ if (binding[0] !== '{') {
413
+ return done(hmrBlock(componentPath, `${binding}.hmr`, `${binding}.dispose`))
136
414
  }
137
415
 
138
- // Pattern 3: bare run(App, ...) — no result captured
139
- // Replace the first run( with __sygnal = run(
140
- transformed = transformed.replace(
141
- /\brun\s*\(/,
142
- 'const __sygnal = run('
143
- )
144
- transformed += hmrBlock(componentPath, '__sygnal.hmr', '__sygnal.dispose')
145
- return { code: transformed, map: null }
416
+ // Pattern 1: const { hmr, dispose, ... } = run(App, ...) — only when both
417
+ // are bound under their own names (a rename would leave them undefined)
418
+ const names = binding.slice(1, -1).split(',').map(s => s.trim())
419
+ if (names.includes('hmr') && names.includes('dispose')) {
420
+ return done(hmrBlock(componentPath, 'hmr', 'dispose'))
421
+ }
422
+ return done()
146
423
  },
147
424
  }
148
425
  }
149
426
 
427
+ /**
428
+ * Major version of the running Vite: from the plugin context (this.meta,
429
+ * Vite 7+), else from the vite package the project resolves. Infinity when
430
+ * unknown (treated as Vite 8+: no `esbuild` option).
431
+ */
432
+ function viteMajor(ctx: any, root: string): number {
433
+ const fromMeta = ctx?.meta?.viteVersion
434
+ if (typeof fromMeta === 'string') return parseInt(fromMeta, 10)
435
+ if (ctx?.meta?.rolldownVersion) return 8
436
+ try {
437
+ const req = createRequire(path.join(root, 'package.json'))
438
+ const v = JSON.parse(fs.readFileSync(req.resolve('vite/package.json'), 'utf8')).version
439
+ const major = parseInt(v, 10)
440
+ if (major > 0) return major
441
+ } catch (_) {}
442
+ return Infinity
443
+ }
444
+
445
+ interface NormalizedDiagnostics { mode: DiagnosticsMode, strict: boolean, ignore: string[] }
446
+
447
+ const MODES: DiagnosticsMode[] = ['off', 'collect', 'warn', 'error']
448
+
449
+ function normalizeDiagnostics(value: SygnalPluginOptions['diagnostics']): NormalizedDiagnostics {
450
+ const o: DiagnosticsPluginOptions = typeof value === 'string' ? { mode: value } : value || {}
451
+ const mode = o.mode === undefined ? 'warn' : o.mode
452
+ if (!MODES.includes(mode)) {
453
+ throw new Error(`[vite-plugin-sygnal] diagnostics mode must be one of ${MODES.map(m => `'${m}'`).join(', ')}; got ${JSON.stringify(mode)}`)
454
+ }
455
+ return { mode, strict: !!o.strict, ignore: Array.isArray(o.ignore) ? o.ignore.map(String) : [] }
456
+ }
457
+
458
+ /** Module id without a query or hash. */
459
+ function cleanId(id: string): string {
460
+ return id.replace(/[?#].*$/, '')
461
+ }
462
+
463
+ /** 'virtual:sygnal/dev': logs sygnal-check results in the browser console. */
464
+ function devClientModule(): string {
465
+ // Vite's client reloads the page on the first HMR update while an error
466
+ // overlay is open. A sygnal-check overlay is closed before each update (the
467
+ // server sends it again after the re-check), so it never causes a reload.
468
+ return `if (import.meta.hot) {
469
+ let shown = 0;
470
+ let ours = false;
471
+ const closeOurs = () => {
472
+ if (!ours || typeof document === 'undefined') return;
473
+ document.querySelectorAll('vite-error-overlay').forEach((n) => n.close ? n.close() : n.remove());
474
+ ours = false;
475
+ };
476
+ import.meta.hot.on('vite:error', (payload) => {
477
+ ours = !!(payload && payload.err && payload.err.plugin === '${OVERLAY_PLUGIN}');
478
+ });
479
+ import.meta.hot.on('vite:beforeUpdate', closeOurs);
480
+ import.meta.hot.on('${CHECK_EVENT}', (payload) => {
481
+ const list = (payload && payload.diagnostics) || [];
482
+ if (!list.some((d) => d.severity === 'error')) closeOurs();
483
+ if (!list.length) {
484
+ if (shown) console.info('[sygnal-check] all findings resolved');
485
+ shown = 0;
486
+ return;
487
+ }
488
+ shown = list.length;
489
+ for (const d of list) console[d.severity === 'error' ? 'error' : 'warn']('[sygnal-check] ' + d.text);
490
+ console.info('[sygnal-check] ' + payload.summary);
491
+ });
492
+ import.meta.hot.send('${CHECK_REQUEST}');
493
+ }
494
+ `
495
+ }
496
+
497
+ /** '\0sygnal-dev:runtime': sygnal with a run() that adds the plugin's diagnostics option. */
498
+ function runtimeModule(d: NormalizedDiagnostics): string {
499
+ const option = JSON.stringify({ mode: d.mode, ignore: d.ignore })
500
+ return `import { run as __run } from 'sygnal';
501
+ export * from 'sygnal';
502
+ export function run(app, drivers, options) {
503
+ if (options && options.diagnostics !== undefined) return __run(app, drivers, options);
504
+ return __run(app, drivers, Object.assign({}, options, { diagnostics: ${option} }));
505
+ }
506
+ `
507
+ }
508
+
509
+ /** Directory of the 'sygnal' package the project resolves, or undefined. */
510
+ function sygnalPackageDir(root: string): string | undefined {
511
+ try {
512
+ const req = createRequire(path.join(root, 'package.json'))
513
+ let dir = path.dirname(req.resolve('sygnal'))
514
+ while (dir !== path.dirname(dir)) {
515
+ const pkgFile = path.join(dir, 'package.json')
516
+ if (fs.existsSync(pkgFile) && JSON.parse(fs.readFileSync(pkgFile, 'utf8')).name === 'sygnal') return dir
517
+ dir = path.dirname(dir)
518
+ }
519
+ } catch (_) {}
520
+ return undefined
521
+ }
522
+
523
+ /** Real paths of sygnal's 'astro/client' ESM file. */
524
+ function astroClientFiles(root: string): Set<string> {
525
+ const files = new Set<string>()
526
+ const dir = sygnalPackageDir(root)
527
+ if (!dir) return files
528
+ try {
529
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'))
530
+ const entry = pkg.exports?.['./astro/client']
531
+ const rel = typeof entry === 'string' ? entry : entry?.import
532
+ if (typeof rel !== 'string') return files
533
+ const file = path.resolve(dir, rel)
534
+ files.add(file.split(path.sep).join('/'))
535
+ files.add(fs.realpathSync(file).split(path.sep).join('/'))
536
+ } catch (_) {}
537
+ return files
538
+ }
539
+
540
+ function sygnalInNodeModules(root: string): boolean {
541
+ const dir = sygnalPackageDir(root)
542
+ if (!dir) return false
543
+ try {
544
+ return fs.realpathSync(dir).split(path.sep).includes('node_modules')
545
+ } catch (_) {
546
+ return false
547
+ }
548
+ }
549
+
550
+ /**
551
+ * Absolute path of the ESM build of 'sygnal/diagnostics' (its "import"
552
+ * condition), so the checks share the core instance the tests import.
553
+ */
554
+ function diagnosticsSetupFile(root: string): string | undefined {
555
+ const dir = sygnalPackageDir(root)
556
+ if (!dir) return undefined
557
+ try {
558
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'))
559
+ const entry = pkg.exports?.['./diagnostics']
560
+ const rel = typeof entry === 'string' ? entry : entry?.import?.default || entry?.import || entry?.default
561
+ if (typeof rel !== 'string') return undefined
562
+ const file = path.resolve(dir, rel)
563
+ return fs.existsSync(file) ? file : undefined
564
+ } catch (_) {
565
+ return undefined
566
+ }
567
+ }
568
+
569
+ /** Load sygnal-check from the project (optional dependency); undefined when it is missing. */
570
+ async function loadSygnalCheck(root: string): Promise<any> {
571
+ try {
572
+ const file = createRequire(path.join(root, 'package.json')).resolve('sygnal-check')
573
+ return await import(/* @vite-ignore */ pathToFileURL(file).href)
574
+ } catch (_) {}
575
+ try {
576
+ const name = 'sygnal-check'
577
+ return await import(/* @vite-ignore */ name)
578
+ } catch (_) {
579
+ return undefined
580
+ }
581
+ }
582
+
583
+ const SOURCE_RE = /\.[cm]?[jt]sx?$/
584
+
585
+ /** sygnal-check's one-line format: `file:line:col CODE [severity] Component: message (fix)` */
586
+ function formatLine(d: any): string {
587
+ const where = d.file ? `${d.file}:${d.line}:${d.column}` : '<sygnal-check>'
588
+ const sev = d.severity === 'warn' ? '' : ` [${d.severity}]`
589
+ return `${where} ${d.code}${sev} ${d.component ? d.component + ': ' : ''}${d.message}${d.fix ? ` (${d.fix})` : ''}`
590
+ }
591
+
592
+ // Source directories checked by default, those that exist (Vike apps use
593
+ // pages/ and renderer/, often without src/)
594
+ const DEFAULT_INCLUDE = ['src', 'pages', 'renderer']
595
+
596
+ /**
597
+ * What sygnal-check checks: `include` when given, else the existing default
598
+ * directories, else the project root (sygnal-check skips node_modules, dist
599
+ * and other build output). Logs one notice when the default finds no
600
+ * directory, or when none of the given paths exists, so an empty check is
601
+ * never a silent all-clear.
602
+ */
603
+ function checkInclude(root: string, include: string[] | undefined, notice: (m: string) => void): string[] {
604
+ const exists = (p: string) => /[*?[{]/.test(p) || fs.existsSync(path.resolve(root, p))
605
+ if (include && include.length) {
606
+ if (!include.some(exists)) notice(`[sygnal] sygnal-check: none of check.include (${include.join(', ')}) exists under ${root}, so nothing is checked`)
607
+ return include
608
+ }
609
+ const dirs = DEFAULT_INCLUDE.filter(exists)
610
+ if (dirs.length) return dirs
611
+ notice(`[sygnal] sygnal-check: no ${DEFAULT_INCLUDE.join('/, ')}/ directory under ${root}; checking the project root (set check.include to choose)`)
612
+ return ['.']
613
+ }
614
+
615
+ /** Run sygnal-check now and after source changes; report to the terminal and the browser. */
616
+ async function startChecker(server: any, root: string, opts: CheckPluginOptions,
617
+ defaults: { strict: boolean, ignore: string[] }, explicit: boolean) {
618
+ const mod = await loadSygnalCheck(root)
619
+ const logger = server?.config?.logger || console
620
+ if (!mod || typeof mod.check !== 'function') {
621
+ if (explicit) logger.info('[sygnal] sygnal-check is not installed, so static checks are off (npm i -D sygnal-check)')
622
+ return
623
+ }
624
+ const include = checkInclude(root, opts.include, (m: string) => logger.info(m))
625
+ const ignore = opts.ignore || defaults.ignore
626
+ const strict = opts.strict === undefined ? defaults.strict : !!opts.strict
627
+ // Only error-severity findings open Vite's overlay: while an overlay is open
628
+ // Vite's client reloads the page on the first HMR update, so warnings stay
629
+ // in the terminal and the browser console. overlay: 'warn' is treated as
630
+ // 'error' (with a one-time notice).
631
+ if (opts.overlay === 'warn') {
632
+ logger.info("[sygnal] check.overlay: 'warn' is treated as 'error': warnings go to the browser console, not Vite's error overlay (an open overlay makes Vite reload the page on the next update)")
633
+ }
634
+ const overlay = opts.overlay !== false
635
+ let lastKey: string | undefined
636
+ let payload: any = null
637
+ let overlayErr: any = null
638
+
639
+ const event = () => ({ type: 'custom', event: CHECK_EVENT, data: payload })
640
+ const errorMessage = () => ({ type: 'error', err: overlayErr })
641
+ // Every connected client (after a source change). An error with no client
642
+ // connected would be buffered by Vite and shown on the next page load.
643
+ const broadcast = (findingsChanged: boolean) => {
644
+ const ws = server.ws
645
+ if (!ws || typeof ws.send !== 'function') return
646
+ try {
647
+ if (findingsChanged && payload) ws.send(event())
648
+ if (overlayErr && !(ws.clients && ws.clients.size === 0)) ws.send(errorMessage())
649
+ } catch (_) {}
650
+ }
651
+
652
+ const run = (initial = false) => {
653
+ let diags: any[]
654
+ try {
655
+ diags = mod.check(include, { cwd: root, strict, ignore })
656
+ } catch (err: any) {
657
+ logger.warn(`[sygnal] sygnal-check failed: ${err?.message || err}`, { timestamp: true })
658
+ return
659
+ }
660
+ const shown = diags.filter(d => d.severity !== 'info')
661
+ const lines = shown.map(formatLine)
662
+ const key = lines.join('\n')
663
+ const changed = key !== lastKey
664
+ if (changed) {
665
+ lastKey = key
666
+ const text: string = typeof mod.formatDiagnostics === 'function'
667
+ ? mod.formatDiagnostics(diags)
668
+ : lines.concat(`sygnal-check: ${shown.length} finding(s)`).join('\n')
669
+ const summary = text.split('\n').pop() || ''
670
+ if (shown.length) logger.warn(text, { timestamp: true })
671
+ else logger.info(summary, { timestamp: true })
672
+ payload = {
673
+ summary,
674
+ diagnostics: shown.map((d, i) => ({ code: d.code, severity: d.severity, file: d.file, line: d.line, column: d.column, text: lines[i] })),
675
+ }
676
+ const forOverlay = overlay ? shown.filter(d => d.severity === 'error') : []
677
+ const first = forOverlay[0]
678
+ overlayErr = first ? {
679
+ plugin: OVERLAY_PLUGIN,
680
+ message: forOverlay.map(formatLine).join('\n'),
681
+ stack: '',
682
+ id: path.resolve(root, first.file || ''),
683
+ loc: { file: path.resolve(root, first.file || ''), line: first.line, column: first.column },
684
+ } : null
685
+ }
686
+ // The first check runs before any page is open; pages ask for the
687
+ // findings when they load (CHECK_REQUEST). After a change the overlay is
688
+ // sent again even when the findings are the same: the HMR update closed it.
689
+ if (!initial) broadcast(changed)
690
+ }
691
+
692
+ run(true)
693
+ // A page that loads (or reloads) asks for the current findings: answer
694
+ // that client only, never every client.
695
+ try {
696
+ server.ws?.on?.(CHECK_REQUEST, (_data: any, client: any) => {
697
+ try {
698
+ if (payload) client?.send?.(event())
699
+ if (overlayErr) client?.send?.(errorMessage())
700
+ } catch (_) {}
701
+ })
702
+ } catch (_) {}
703
+
704
+ let timer: any
705
+ const onChange = (file: string) => {
706
+ if (!SOURCE_RE.test(file) || file.split(/[\\/]/).includes('node_modules')) return
707
+ clearTimeout(timer)
708
+ timer = setTimeout(run, 100)
709
+ }
710
+ const watcher = server?.watcher
711
+ if (watcher && typeof watcher.on === 'function') {
712
+ watcher.on('change', onChange)
713
+ watcher.on('add', onChange)
714
+ watcher.on('unlink', onChange)
715
+ }
716
+ try { server.httpServer?.once?.('close', () => clearTimeout(timer)) } catch (_) {}
717
+ }
718
+
719
+ const DEV_FLAG = 'if (globalThis.__SYGNAL_DEV__ === undefined) globalThis.__SYGNAL_DEV__ = true;'
720
+ const STRICT_FLAG = 'if (globalThis.__SYGNAL_STRICT__ === undefined) globalThis.__SYGNAL_STRICT__ = true;'
721
+
722
+ // A top-level (column 0) run(Component, ...) statement: bare, or declaring a
723
+ // new binding. Assignments to existing variables, nested calls and indented
724
+ // calls (e.g. inside a function or test callback) do not match (B-007). It is
725
+ // matched against the code with comments and strings blanked out.
726
+ const RUN_CALL_RE = /^(?:(?:const|let|var)\s+(\{[^}]*\}|[\w$]+)\s*=\s*)?run\s*\(\s*([A-Z][\w$]*)/m
727
+
728
+ // Comments, string literals and template literals (a regex literal containing
729
+ // a quote or `//` can over-blank; that only ever skips HMR wiring).
730
+ const COMMENT_OR_STRING_RE = /\/\/[^\n]*|\/\*[\s\S]*?(?:\*\/|$)|(['"])(?:\\.|(?!\1)[^\\\n])*\1?|`(?:\\[\s\S]|[^\\`])*`?/g
731
+
732
+ /** Replace comments (and, with `strings`, string/template literals) by spaces, keeping offsets and newlines. */
733
+ function blankOut(code: string, strings: boolean): string {
734
+ return code.replace(COMMENT_OR_STRING_RE, m =>
735
+ !strings && m[0] !== '/' ? m : m.replace(/[^\n]/g, ' ')
736
+ )
737
+ }
738
+
739
+ // One directive ('use strict', "use client", ...) with any leading whitespace
740
+ // and comments. It must end with `;` (group 2) or at the end of its line.
741
+ const DIRECTIVE_RE = /(?:\s|\/\/[^\n]*|\/\*[\s\S]*?\*\/)*(['"])(?:\\.|(?!\1)[^\\\n])*\1(?:[ \t]*(;)|(?=[ \t]*(?:\/\/[^\n]*|\/\*[^\n]*?\*\/)?[ \t]*(?:\r?\n|$)))/y
742
+
743
+ /** Insert `text` (the dev snippet) after a shebang line and the directive prologue. */
744
+ function flagInsertion(code: string, text: string): [number, string] {
745
+ let at = code.startsWith('#!') ? (code.indexOf('\n') + 1 || code.length) : 0
746
+ let semicolon = true
747
+ DIRECTIVE_RE.lastIndex = at
748
+ let m
749
+ while ((m = DIRECTIVE_RE.exec(code))) {
750
+ at = DIRECTIVE_RE.lastIndex
751
+ semicolon = !!m[2]
752
+ }
753
+ return [at, (semicolon ? '' : ';') + text]
754
+ }
755
+
756
+ const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
757
+
758
+ function vlq(n: number): string {
759
+ let v = n < 0 ? (-n << 1) | 1 : n << 1
760
+ let out = ''
761
+ do {
762
+ let digit = v & 31
763
+ v >>>= 5
764
+ if (v) digit |= 32
765
+ out += B64[digit]
766
+ } while (v)
767
+ return out
768
+ }
769
+
770
+ /**
771
+ * Apply single-line insertions (at offsets in the original code) plus an
772
+ * appended tail, and build a v3 sourcemap without extra dependencies. No line
773
+ * is added before the tail, so every original line maps to itself; on a line
774
+ * with an insertion, the text after it maps back to its original column.
775
+ */
776
+ function withSourcemap(code: string, inserts: Array<[number, string]>, tail: string, id: string) {
777
+ inserts.sort((a, b) => a[0] - b[0])
778
+ let out = ''
779
+ let last = 0
780
+ for (const [at, text] of inserts) {
781
+ out += code.slice(last, at) + text
782
+ last = at
783
+ }
784
+ out += code.slice(last) + tail
785
+
786
+ let offset = 0
787
+ let k = 0
788
+ let prevLine = 0
789
+ let prevCol = 0
790
+ const mappings = code.split('\n').map((line, i) => {
791
+ const segments: Array<[number, number]> = [[0, 0]] // [generated column, original column]
792
+ let shift = 0
793
+ while (k < inserts.length && inserts[k][0] <= offset + line.length) {
794
+ const col = inserts[k][0] - offset
795
+ shift += inserts[k][1].length
796
+ segments.push([col + shift, col])
797
+ k++
798
+ }
799
+ offset += line.length + 1
800
+ let genCol = 0
801
+ return segments.map(([gen, col]) => {
802
+ const seg = vlq(gen - genCol) + 'A' + vlq(i - prevLine) + vlq(col - prevCol)
803
+ genCol = gen
804
+ prevLine = i
805
+ prevCol = col
806
+ return seg
807
+ }).join(',')
808
+ }).join(';')
809
+
810
+ return {
811
+ code: out,
812
+ map: { version: 3, sources: [id], sourcesContent: [code], names: [] as string[], mappings },
813
+ }
814
+ }
815
+
150
816
  function hmrBlock(componentPath: string, hmrRef: string, disposeRef: string): string {
151
817
  return `
152
818
  if (import.meta.hot) {