@reticlehq/vite-plugin 2.3.0 → 2.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.
- package/dist/index.d.ts +27 -1
- package/dist/index.js +137 -14
- package/dist/svelte-source.d.ts +66 -0
- package/dist/svelte-source.js +166 -0
- package/package.json +4 -3
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
export declare const RETICLE_VITE_PLUGIN_NAME = "reticle";
|
|
2
|
+
/**
|
|
3
|
+
* Compile-time global carrying the daemon's pairing token, for connects the plugin does not write
|
|
4
|
+
* itself. The bridge requires the token even on localhost, and nothing in a browser can read the
|
|
5
|
+
* file it lives in.
|
|
6
|
+
*/
|
|
7
|
+
export declare const RETICLE_TOKEN_GLOBAL = "__RETICLE_TOKEN__";
|
|
2
8
|
/**
|
|
3
9
|
* The connect code is served as a real module (not an inline <script>) so that Vite's import
|
|
4
10
|
* pipeline resolves the bare `@reticlehq/react` specifier. An inline injected script is NOT run through
|
|
@@ -79,11 +85,20 @@ export interface ReticleVitePlugin {
|
|
|
79
85
|
config?: (config: {
|
|
80
86
|
optimizeDeps?: {
|
|
81
87
|
include?: string[];
|
|
88
|
+
esbuildOptions?: {
|
|
89
|
+
define?: Record<string, string>;
|
|
90
|
+
};
|
|
82
91
|
};
|
|
92
|
+
define?: Record<string, string>;
|
|
93
|
+
root?: string;
|
|
83
94
|
}) => {
|
|
84
95
|
optimizeDeps: {
|
|
85
96
|
include: string[];
|
|
97
|
+
esbuildOptions: {
|
|
98
|
+
define: Record<string, string>;
|
|
99
|
+
};
|
|
86
100
|
};
|
|
101
|
+
define: Record<string, string>;
|
|
87
102
|
};
|
|
88
103
|
/** Absent in desktop mode, where the plugin must also run for `vite build`. */
|
|
89
104
|
apply?: 'serve';
|
|
@@ -116,7 +131,18 @@ interface HtmlTag {
|
|
|
116
131
|
}
|
|
117
132
|
export declare function readPairingToken(): string | undefined;
|
|
118
133
|
/** The body of the connect module — real imports, resolved by Vite when the module is served. */
|
|
119
|
-
|
|
134
|
+
/**
|
|
135
|
+
* The conventional app-side dev module: `registerStore` / `registerCapabilities` live here.
|
|
136
|
+
*
|
|
137
|
+
* It is imported by CONVENTION rather than by patching the app's entry file. The connect is injected
|
|
138
|
+
* into a virtual module, so there is nowhere for a user to add these calls without `init` editing
|
|
139
|
+
* `src/main.tsx` — an edit to the file people actually own, for something that is opt-in enrichment.
|
|
140
|
+
* Convention costs one `existsSync` and leaves their entry untouched.
|
|
141
|
+
*/
|
|
142
|
+
export declare const RETICLE_DEV_MODULE_CANDIDATES: readonly ["src/reticle-dev.ts", "src/reticle-dev.js", "src/reticle-dev.tsx", "src/reticle-dev.jsx"];
|
|
143
|
+
/** The app's dev module, as an importable path — or null when the app has none. */
|
|
144
|
+
export declare function findDevModule(root: string, exists: (p: string) => boolean): string | null;
|
|
145
|
+
export declare function connectModuleSource(options: ReticleVitePluginOptions, devModule?: string | null): string;
|
|
120
146
|
/**
|
|
121
147
|
* Reticle Vite plugin. Add to your `plugins` array and the entire integration is done:
|
|
122
148
|
*
|
package/dist/index.js
CHANGED
|
@@ -1,16 +1,61 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { transformSync } from '@babel/core';
|
|
5
5
|
import reticleSource from '@reticlehq/babel-plugin';
|
|
6
|
-
import { RETICLE_DEFAULT_PORT, RETICLE_RENDER_PREHOOK, bridgeWsUrl, ReticleDir, ReticleEnv, } from '@reticlehq/core';
|
|
6
|
+
import { RETICLE_DEFAULT_PORT, RETICLE_RENDER_PREHOOK, bridgeWsUrl, ReticleDir, ReticleEnv, RETICLE_ROOT_GLOBAL, } from '@reticlehq/core';
|
|
7
7
|
import { resolveProjectId } from './project-id.js';
|
|
8
8
|
import { discoverDaemonPort } from './discover-port.js';
|
|
9
|
+
import { SVELTE_FILE, stampSvelte } from './svelte-source.js';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
9
11
|
export const RETICLE_VITE_PLUGIN_NAME = 'reticle';
|
|
10
12
|
// The React kit the host app imports the SDK from. It re-exports the browser sensor, so a single
|
|
11
13
|
// specifier yields both `reticle` (connect) and `install` (the React adapter). NOT `@reticlehq/core`
|
|
12
14
|
// — that is the isomorphic foundation and exports neither.
|
|
13
15
|
const RETICLE_PACKAGE = '@reticlehq/react';
|
|
16
|
+
/**
|
|
17
|
+
* Compile-time global carrying the daemon's pairing token, for connects the plugin does not write
|
|
18
|
+
* itself. The bridge requires the token even on localhost, and nothing in a browser can read the
|
|
19
|
+
* file it lives in.
|
|
20
|
+
*/
|
|
21
|
+
export const RETICLE_TOKEN_GLOBAL = '__RETICLE_TOKEN__';
|
|
22
|
+
/**
|
|
23
|
+
* Whether a package can be resolved from this process. Used to avoid declaring an optimizeDeps entry
|
|
24
|
+
* for something the app does not have, which Vite reports as a resolve failure on every boot.
|
|
25
|
+
*/
|
|
26
|
+
function isResolvable(specifier) {
|
|
27
|
+
try {
|
|
28
|
+
createRequire(import.meta.url).resolve(specifier);
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* A fingerprint of the installed SDK build, mixed into `optimizeDeps` so Vite re-bundles when the
|
|
37
|
+
* SDK changes.
|
|
38
|
+
*
|
|
39
|
+
* Vite's dep-optimizer cache is keyed on the `optimizeDeps` config and the lockfile — NOT on the
|
|
40
|
+
* contents of the packages it bundled. Upgrade the SDK in place (a patched dist, a linked checkout,
|
|
41
|
+
* an overlay) and the version in `package.json` can stay the same, so Vite keeps serving the OLD
|
|
42
|
+
* pre-bundled copy out of `node_modules/.vite` across dev-server restarts. The fix you just shipped
|
|
43
|
+
* is simply not in the browser, and it looks like the fix does not work. That cost a real
|
|
44
|
+
* false-negative during this bug hunt, and every user upgrading in place hits the same thing.
|
|
45
|
+
*
|
|
46
|
+
* Size+mtime is enough: it changes whenever the bundle does and costs one `stat`.
|
|
47
|
+
*/
|
|
48
|
+
function sdkBuildFingerprint() {
|
|
49
|
+
try {
|
|
50
|
+
const entry = createRequire(import.meta.url).resolve(RETICLE_PACKAGE);
|
|
51
|
+
const { size, mtimeMs } = statSync(entry);
|
|
52
|
+
return `${String(size)}-${String(Math.trunc(mtimeMs))}`;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Unresolvable (not installed yet, exotic layout) — a constant is still correct, just inert.
|
|
56
|
+
return 'unknown';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
14
59
|
/** Files we stamp with source info — JSX/TSX only. */
|
|
15
60
|
const JSX_FILE = /\.[jt]sx$/;
|
|
16
61
|
/** Rollup virtual-module ids start with a NUL byte; never transform those. */
|
|
@@ -77,14 +122,23 @@ function isHtmlEntry(id, specifier, root) {
|
|
|
77
122
|
// buildEnd post-condition means a wrong match cannot pass unnoticed as "nothing happened".
|
|
78
123
|
return candidate.endsWith(target.startsWith('/') ? target : `/${target}`);
|
|
79
124
|
}
|
|
80
|
-
|
|
125
|
+
/** A module id we may stamp at all: not virtual, not a dependency. Extension decides which stamper. */
|
|
126
|
+
function stampableId(id) {
|
|
81
127
|
if (id.startsWith(VIRTUAL_PREFIX))
|
|
82
|
-
return
|
|
128
|
+
return null;
|
|
83
129
|
if (id.includes(NODE_MODULES))
|
|
84
|
-
return
|
|
130
|
+
return null;
|
|
85
131
|
// Strip any query suffix (?worker, ?raw,...) before matching the extension.
|
|
86
|
-
|
|
87
|
-
|
|
132
|
+
return id.split('?')[0] ?? id;
|
|
133
|
+
}
|
|
134
|
+
function shouldStamp(id) {
|
|
135
|
+
const clean = stampableId(id);
|
|
136
|
+
return clean !== null && JSX_FILE.test(clean);
|
|
137
|
+
}
|
|
138
|
+
/** A `.svelte` single-file component, which needs the Svelte stamper rather than Babel. */
|
|
139
|
+
function shouldStampSvelte(id) {
|
|
140
|
+
const clean = stampableId(id);
|
|
141
|
+
return clean !== null && SVELTE_FILE.test(clean);
|
|
88
142
|
}
|
|
89
143
|
function stamp(code, id) {
|
|
90
144
|
const out = transformSync(code, {
|
|
@@ -152,9 +206,34 @@ function connectArgs(options) {
|
|
|
152
206
|
return Object.keys(args).length > 0 ? JSON.stringify(args) : '';
|
|
153
207
|
}
|
|
154
208
|
/** The body of the connect module — real imports, resolved by Vite when the module is served. */
|
|
155
|
-
|
|
209
|
+
/**
|
|
210
|
+
* The conventional app-side dev module: `registerStore` / `registerCapabilities` live here.
|
|
211
|
+
*
|
|
212
|
+
* It is imported by CONVENTION rather than by patching the app's entry file. The connect is injected
|
|
213
|
+
* into a virtual module, so there is nowhere for a user to add these calls without `init` editing
|
|
214
|
+
* `src/main.tsx` — an edit to the file people actually own, for something that is opt-in enrichment.
|
|
215
|
+
* Convention costs one `existsSync` and leaves their entry untouched.
|
|
216
|
+
*/
|
|
217
|
+
export const RETICLE_DEV_MODULE_CANDIDATES = [
|
|
218
|
+
'src/reticle-dev.ts',
|
|
219
|
+
'src/reticle-dev.js',
|
|
220
|
+
'src/reticle-dev.tsx',
|
|
221
|
+
'src/reticle-dev.jsx',
|
|
222
|
+
];
|
|
223
|
+
/** The app's dev module, as an importable path — or null when the app has none. */
|
|
224
|
+
export function findDevModule(root, exists) {
|
|
225
|
+
for (const rel of RETICLE_DEV_MODULE_CANDIDATES) {
|
|
226
|
+
if (exists(`${root}/${rel}`))
|
|
227
|
+
return `/${rel}`;
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
export function connectModuleSource(options, devModule = null) {
|
|
156
232
|
const args = connectArgs(options);
|
|
157
|
-
|
|
233
|
+
const base = `import { reticle, install } from '${RETICLE_PACKAGE}';\ninstall();\nreticle.connect(${args});\n`;
|
|
234
|
+
// AFTER connect: registerStore subscribes through the live SDK, and registering before there is a
|
|
235
|
+
// session to report into drops the first diffs.
|
|
236
|
+
return devModule === null ? base : `${base}import('${devModule}');\n`;
|
|
158
237
|
}
|
|
159
238
|
/**
|
|
160
239
|
* Reticle Vite plugin. Add to your `plugins` array and the entire integration is done:
|
|
@@ -256,15 +335,48 @@ export function reticle(options = {}) {
|
|
|
256
335
|
* developer has never heard of. Measured on the react-admin demo with the SDK aliased to a local
|
|
257
336
|
* checkout: zero sessions, and it looked like the app was failing to render.
|
|
258
337
|
*
|
|
259
|
-
* Declaring them is free when Vite would have found them anyway
|
|
338
|
+
* Declaring them is free when Vite would have found them anyway — but only when they are
|
|
339
|
+
* actually installed. Naming a package that is not there makes Vite log `Failed to resolve
|
|
340
|
+
* dependency: …, present in optimizeDeps.include` on every boot, which is a scary line pointing
|
|
341
|
+
* at Reticle for a problem that does not exist. SvelteKit apps hit exactly that: nothing in that
|
|
342
|
+
* tree depends on @testing-library/dom.
|
|
260
343
|
*/
|
|
261
344
|
config(config) {
|
|
262
345
|
return {
|
|
346
|
+
// Expose the daemon's pairing token to hand-written connects in the same Vite app. The
|
|
347
|
+
// plugin's own injected connect gets the token directly, but a connect the USER writes —
|
|
348
|
+
// SvelteKit's client hook, a custom entry — had no way to reach a file only Node can read,
|
|
349
|
+
// so it called connect() with no credential and the bridge answered "authentication
|
|
350
|
+
// failed". Empty until the daemon has provisioned one; the page reloads once it has.
|
|
351
|
+
define: {
|
|
352
|
+
...(config.define ?? {}),
|
|
353
|
+
[RETICLE_TOKEN_GLOBAL]: JSON.stringify(readPairingToken() ?? ''),
|
|
354
|
+
// Lets the SDK report React's absolute `_debugSource.fileName` as a repo-relative path,
|
|
355
|
+
// so source looks the same whichever React version an app is on.
|
|
356
|
+
[RETICLE_ROOT_GLOBAL]: JSON.stringify(config.root ?? process.cwd()),
|
|
357
|
+
},
|
|
263
358
|
optimizeDeps: {
|
|
359
|
+
// Part of the cache key, not of the build: changing it is what makes Vite notice that the
|
|
360
|
+
// SDK on disk is not the SDK it pre-bundled. See sdkBuildFingerprint.
|
|
361
|
+
esbuildOptions: {
|
|
362
|
+
...(config.optimizeDeps?.esbuildOptions ?? {}),
|
|
363
|
+
define: {
|
|
364
|
+
...(config.optimizeDeps?.esbuildOptions?.define ?? {}),
|
|
365
|
+
__RETICLE_SDK_BUILD__: JSON.stringify(sdkBuildFingerprint()),
|
|
366
|
+
},
|
|
367
|
+
},
|
|
264
368
|
include: [
|
|
265
369
|
...(config.optimizeDeps?.include ?? []),
|
|
266
|
-
|
|
267
|
-
|
|
370
|
+
// The SDK ITSELF. Without this, Vite does not learn about @reticlehq/react until the
|
|
371
|
+
// injected connect module is requested — mid-flight, on the very first page load. It
|
|
372
|
+
// then pre-bundles it and forces a full reload, and the connect is lost in that reload:
|
|
373
|
+
// no WebSocket, no session, no console message. The FIRST load after `reticle init` —
|
|
374
|
+
// the one the whole product is judged on — silently did nothing, and it worked on the
|
|
375
|
+
// next refresh, which is the worst possible shape for a bug like this.
|
|
376
|
+
RETICLE_PACKAGE,
|
|
377
|
+
// Only if present — see above; naming an absent package produces a boot warning that
|
|
378
|
+
// blames Reticle for nothing.
|
|
379
|
+
...[SDK_CJS_DEPS.TESTING_LIBRARY, SDK_CJS_DEPS.ARIA_QUERY].filter(isResolvable),
|
|
268
380
|
],
|
|
269
381
|
},
|
|
270
382
|
};
|
|
@@ -279,7 +391,16 @@ export function reticle(options = {}) {
|
|
|
279
391
|
const stamped = sourceMapping && shouldStamp(id) ? stamp(withConnect, id) : null;
|
|
280
392
|
return stamped ?? { code: withConnect, map: null };
|
|
281
393
|
}
|
|
282
|
-
if (!sourceMapping
|
|
394
|
+
if (!sourceMapping)
|
|
395
|
+
return null;
|
|
396
|
+
// `.svelte` runs on the RAW component source, which is only still markup because this plugin
|
|
397
|
+
// declares `enforce: 'pre'` and therefore transforms before @sveltejs/vite-plugin-svelte. No
|
|
398
|
+
// map: the insertions are within a line and never move one, and a wrong map is worse than none.
|
|
399
|
+
if (shouldStampSvelte(id)) {
|
|
400
|
+
const stamped = stampSvelte(code, id);
|
|
401
|
+
return stamped === null ? null : { code: stamped, map: null };
|
|
402
|
+
}
|
|
403
|
+
if (!shouldStamp(id))
|
|
283
404
|
return null;
|
|
284
405
|
return stamp(code, id);
|
|
285
406
|
},
|
|
@@ -301,7 +422,9 @@ export function reticle(options = {}) {
|
|
|
301
422
|
load(id) {
|
|
302
423
|
if (!inject || id !== RETICLE_CONNECT_MODULE)
|
|
303
424
|
return null;
|
|
304
|
-
|
|
425
|
+
// Resolved at load, not at config: the file may be created after the dev server starts.
|
|
426
|
+
const devModule = root === undefined ? null : findDevModule(root, existsSync);
|
|
427
|
+
return connectModuleSource(resolveLazy(), devModule);
|
|
305
428
|
},
|
|
306
429
|
configResolved(config) {
|
|
307
430
|
root = config.root;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `data-reticle-source` for `.svelte` single-file components.
|
|
3
|
+
*
|
|
4
|
+
* A SvelteKit app onboards, connects, and gets DOM, network, console, routing and storage — and then
|
|
5
|
+
* every verdict comes back without the `file:line` the whole product leads with, because the JSX
|
|
6
|
+
* stamper is Babel and a `.svelte` file is not JavaScript. This is the Svelte half.
|
|
7
|
+
*
|
|
8
|
+
* ## Where in the pipeline
|
|
9
|
+
*
|
|
10
|
+
* A Vite `transform` on the RAW component source, in the Reticle plugin, which already declares
|
|
11
|
+
* `enforce: 'pre'` — so it runs before `@sveltejs/vite-plugin-svelte` and hands the compiler markup
|
|
12
|
+
* that already carries the attribute. Getting the order wrong is the failure mode worth naming: run
|
|
13
|
+
* it after, and there is no markup left to stamp, only generated JavaScript whose original line
|
|
14
|
+
* numbers are gone. A Svelte *preprocessor* would also work and would be equally correct, but it has
|
|
15
|
+
* to be wired into `svelte.config.js` by the user, and an integration nobody enables is an
|
|
16
|
+
* integration that does not exist.
|
|
17
|
+
*
|
|
18
|
+
* ## Why the compiler is loaded the way it is
|
|
19
|
+
*
|
|
20
|
+
* `svelte` must never become a dependency of this package: a React project installing the Vite
|
|
21
|
+
* plugin must not acquire a Svelte compiler, and its build must be unaffected. So the compiler is
|
|
22
|
+
* resolved lazily, from the APP's directory, only when a `.svelte` id actually arrives, and its
|
|
23
|
+
* absence returns null rather than throwing. Same shape as the optional `playwright` import in the
|
|
24
|
+
* server's pool launcher.
|
|
25
|
+
*
|
|
26
|
+
* Resolution is synchronous (`createRequire`) rather than `await import()` because Svelte ships a CJS
|
|
27
|
+
* build of its compiler and the plugin's `transform` is synchronous — making the whole hook async to
|
|
28
|
+
* fetch an optional dependency would change the contract of every other path through it.
|
|
29
|
+
*
|
|
30
|
+
* ## Scope
|
|
31
|
+
*
|
|
32
|
+
* The attribute only. Mapping a DOM node to the COMPONENT that rendered it — what `@reticlehq/react`
|
|
33
|
+
* does through the fiber tree — is a separate and much larger problem, and is not attempted here.
|
|
34
|
+
*/
|
|
35
|
+
/** Files this module stamps. `.svelte.ts` (a runes module) is code, not markup — excluded. */
|
|
36
|
+
export declare const SVELTE_FILE: RegExp;
|
|
37
|
+
/** The part of `svelte/compiler` this module uses. Structural, so `svelte` stays un-imported. */
|
|
38
|
+
export interface SvelteCompilerLike {
|
|
39
|
+
parse: (source: string, options?: {
|
|
40
|
+
modern?: boolean;
|
|
41
|
+
}) => unknown;
|
|
42
|
+
}
|
|
43
|
+
/** How the compiler is obtained. Injected in tests; the default resolves it from the app. */
|
|
44
|
+
export type LoadSvelteCompiler = () => SvelteCompilerLike | null;
|
|
45
|
+
/**
|
|
46
|
+
* A character offset as Babel would report it: 1-based line, 0-based column.
|
|
47
|
+
*
|
|
48
|
+
* Matching Babel exactly is not cosmetic — `parseSourceAttr` in the browser SDK reads one format,
|
|
49
|
+
* and a Svelte pointer that was 0-based on line would land every verdict one line off while looking
|
|
50
|
+
* completely plausible.
|
|
51
|
+
*/
|
|
52
|
+
export declare function offsetToLineColumn(source: string, offset: number): {
|
|
53
|
+
line: number;
|
|
54
|
+
column: number;
|
|
55
|
+
};
|
|
56
|
+
/** Project-relative, forward-slashed. A pointer must be the same string on Windows as on Linux. */
|
|
57
|
+
export declare function sourcePathFor(id: string, cwd?: string): string;
|
|
58
|
+
/**
|
|
59
|
+
* Stamp `data-reticle-source="file:line:column"` on every host element of a `.svelte` component.
|
|
60
|
+
*
|
|
61
|
+
* Returns null — never throws — when the compiler is absent or the component does not parse. In dev
|
|
62
|
+
* the transform runs on every keystroke, so a half-typed component is the NORMAL case; failing the
|
|
63
|
+
* build over one would make Reticle the reason the dev server is red, for a feature that is pure
|
|
64
|
+
* enrichment on top of an app that otherwise works.
|
|
65
|
+
*/
|
|
66
|
+
export declare function stampSvelte(code: string, id: string, load?: LoadSvelteCompiler): string | null;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { relative } from 'node:path';
|
|
3
|
+
import { DATA_RETICLE_SOURCE_ATTR } from '@reticlehq/core';
|
|
4
|
+
/**
|
|
5
|
+
* `data-reticle-source` for `.svelte` single-file components.
|
|
6
|
+
*
|
|
7
|
+
* A SvelteKit app onboards, connects, and gets DOM, network, console, routing and storage — and then
|
|
8
|
+
* every verdict comes back without the `file:line` the whole product leads with, because the JSX
|
|
9
|
+
* stamper is Babel and a `.svelte` file is not JavaScript. This is the Svelte half.
|
|
10
|
+
*
|
|
11
|
+
* ## Where in the pipeline
|
|
12
|
+
*
|
|
13
|
+
* A Vite `transform` on the RAW component source, in the Reticle plugin, which already declares
|
|
14
|
+
* `enforce: 'pre'` — so it runs before `@sveltejs/vite-plugin-svelte` and hands the compiler markup
|
|
15
|
+
* that already carries the attribute. Getting the order wrong is the failure mode worth naming: run
|
|
16
|
+
* it after, and there is no markup left to stamp, only generated JavaScript whose original line
|
|
17
|
+
* numbers are gone. A Svelte *preprocessor* would also work and would be equally correct, but it has
|
|
18
|
+
* to be wired into `svelte.config.js` by the user, and an integration nobody enables is an
|
|
19
|
+
* integration that does not exist.
|
|
20
|
+
*
|
|
21
|
+
* ## Why the compiler is loaded the way it is
|
|
22
|
+
*
|
|
23
|
+
* `svelte` must never become a dependency of this package: a React project installing the Vite
|
|
24
|
+
* plugin must not acquire a Svelte compiler, and its build must be unaffected. So the compiler is
|
|
25
|
+
* resolved lazily, from the APP's directory, only when a `.svelte` id actually arrives, and its
|
|
26
|
+
* absence returns null rather than throwing. Same shape as the optional `playwright` import in the
|
|
27
|
+
* server's pool launcher.
|
|
28
|
+
*
|
|
29
|
+
* Resolution is synchronous (`createRequire`) rather than `await import()` because Svelte ships a CJS
|
|
30
|
+
* build of its compiler and the plugin's `transform` is synchronous — making the whole hook async to
|
|
31
|
+
* fetch an optional dependency would change the contract of every other path through it.
|
|
32
|
+
*
|
|
33
|
+
* ## Scope
|
|
34
|
+
*
|
|
35
|
+
* The attribute only. Mapping a DOM node to the COMPONENT that rendered it — what `@reticlehq/react`
|
|
36
|
+
* does through the fiber tree — is a separate and much larger problem, and is not attempted here.
|
|
37
|
+
*/
|
|
38
|
+
/** Files this module stamps. `.svelte.ts` (a runes module) is code, not markup — excluded. */
|
|
39
|
+
export const SVELTE_FILE = /\.svelte$/;
|
|
40
|
+
/** Element node types that are a real place in the DOM, in Svelte 5's AST and Svelte 4's. */
|
|
41
|
+
const HOST_ELEMENT_TYPES = new Set(['RegularElement', 'Element']);
|
|
42
|
+
/** The `parent` back-reference some AST shapes carry; following it would walk forever. */
|
|
43
|
+
const PARENT_KEY = 'parent';
|
|
44
|
+
/** Resolved once per process: `null` means "looked, not installed" and must not be retried per file. */
|
|
45
|
+
let cachedCompiler;
|
|
46
|
+
function defaultLoadCompiler() {
|
|
47
|
+
if (cachedCompiler !== undefined)
|
|
48
|
+
return cachedCompiler;
|
|
49
|
+
cachedCompiler = null;
|
|
50
|
+
// From the APP's root first. The plugin may be linked, hoisted, or in a pnpm store far from the
|
|
51
|
+
// project, and the compiler that matters is the one the app's own Svelte plugin will use.
|
|
52
|
+
for (const from of [`${process.cwd()}/package.json`, import.meta.url]) {
|
|
53
|
+
try {
|
|
54
|
+
cachedCompiler = createRequire(from)('svelte/compiler');
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Not resolvable from here — try the next origin, then give up silently.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return cachedCompiler;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* A character offset as Babel would report it: 1-based line, 0-based column.
|
|
65
|
+
*
|
|
66
|
+
* Matching Babel exactly is not cosmetic — `parseSourceAttr` in the browser SDK reads one format,
|
|
67
|
+
* and a Svelte pointer that was 0-based on line would land every verdict one line off while looking
|
|
68
|
+
* completely plausible.
|
|
69
|
+
*/
|
|
70
|
+
export function offsetToLineColumn(source, offset) {
|
|
71
|
+
let line = 1;
|
|
72
|
+
let lineStart = 0;
|
|
73
|
+
for (let i = 0; i < offset && i < source.length; i++) {
|
|
74
|
+
if (source[i] === '\n') {
|
|
75
|
+
line += 1;
|
|
76
|
+
lineStart = i + 1;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { line, column: offset - lineStart };
|
|
80
|
+
}
|
|
81
|
+
/** Project-relative, forward-slashed. A pointer must be the same string on Windows as on Linux. */
|
|
82
|
+
export function sourcePathFor(id, cwd = process.cwd()) {
|
|
83
|
+
return relative(cwd, id).replace(/\\/g, '/');
|
|
84
|
+
}
|
|
85
|
+
function isElementNode(value) {
|
|
86
|
+
if (value === null || typeof value !== 'object')
|
|
87
|
+
return false;
|
|
88
|
+
const node = value;
|
|
89
|
+
return (typeof node.type === 'string' &&
|
|
90
|
+
HOST_ELEMENT_TYPES.has(node.type) &&
|
|
91
|
+
typeof node.name === 'string' &&
|
|
92
|
+
typeof node.start === 'number');
|
|
93
|
+
}
|
|
94
|
+
function isAlreadyStamped(node) {
|
|
95
|
+
return (node.attributes ?? []).some((attr) => attr !== null &&
|
|
96
|
+
typeof attr === 'object' &&
|
|
97
|
+
attr.name === DATA_RETICLE_SOURCE_ATTR);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Collect every host element in the AST.
|
|
101
|
+
*
|
|
102
|
+
* A GENERIC walk over the object graph rather than a switch over Svelte's block types. `{#if}`,
|
|
103
|
+
* `{#each}`, `{#await}`, `{#key}`, `{#snippet}` and whatever the next release adds each nest their
|
|
104
|
+
* children under a differently-named field, and a hand-written descent silently misses the ones it
|
|
105
|
+
* has not heard of — elements inside an `{#each}` would simply never be stamped, with nothing to
|
|
106
|
+
* indicate they were skipped.
|
|
107
|
+
*/
|
|
108
|
+
function collectElements(root) {
|
|
109
|
+
const found = [];
|
|
110
|
+
const seen = new Set();
|
|
111
|
+
const visit = (value) => {
|
|
112
|
+
if (value === null || typeof value !== 'object' || seen.has(value))
|
|
113
|
+
return;
|
|
114
|
+
seen.add(value);
|
|
115
|
+
if (Array.isArray(value)) {
|
|
116
|
+
for (const item of value)
|
|
117
|
+
visit(item);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (isElementNode(value) && !isAlreadyStamped(value))
|
|
121
|
+
found.push(value);
|
|
122
|
+
for (const [key, child] of Object.entries(value)) {
|
|
123
|
+
if (key !== PARENT_KEY)
|
|
124
|
+
visit(child);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
visit(root);
|
|
128
|
+
return found;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Stamp `data-reticle-source="file:line:column"` on every host element of a `.svelte` component.
|
|
132
|
+
*
|
|
133
|
+
* Returns null — never throws — when the compiler is absent or the component does not parse. In dev
|
|
134
|
+
* the transform runs on every keystroke, so a half-typed component is the NORMAL case; failing the
|
|
135
|
+
* build over one would make Reticle the reason the dev server is red, for a feature that is pure
|
|
136
|
+
* enrichment on top of an app that otherwise works.
|
|
137
|
+
*/
|
|
138
|
+
export function stampSvelte(code, id, load = defaultLoadCompiler) {
|
|
139
|
+
const compiler = load();
|
|
140
|
+
if (compiler === null)
|
|
141
|
+
return null;
|
|
142
|
+
let ast;
|
|
143
|
+
try {
|
|
144
|
+
// `modern: true` selects Svelte 5's AST; Svelte 4 ignores the option and returns its own shape.
|
|
145
|
+
// `collectElements` accepts both, so one call covers either major without a version probe.
|
|
146
|
+
ast = compiler.parse(code, { modern: true });
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
const elements = collectElements(ast);
|
|
152
|
+
if (elements.length === 0)
|
|
153
|
+
return null;
|
|
154
|
+
const file = sourcePathFor(id);
|
|
155
|
+
// Insert from the LAST element backwards: every insertion shifts the offsets after it, and
|
|
156
|
+
// applying them in source order would put each stamp progressively further from its own tag.
|
|
157
|
+
const ordered = [...elements].sort((a, b) => b.start - a.start);
|
|
158
|
+
let out = code;
|
|
159
|
+
for (const node of ordered) {
|
|
160
|
+
const { line, column } = offsetToLineColumn(code, node.start);
|
|
161
|
+
const insertAt = node.start + 1 + node.name.length;
|
|
162
|
+
const attribute = ` ${DATA_RETICLE_SOURCE_ATTR}="${file}:${String(line)}:${String(column)}"`;
|
|
163
|
+
out = `${out.slice(0, insertAt)}${attribute}${out.slice(insertAt)}`;
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reticlehq/vite-plugin",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "Vite plugin for Reticle: dev-only source-map stamping plus auto-injected reticle.connect(). apply:'serve' guarantees it never ships to production.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -34,11 +34,12 @@
|
|
|
34
34
|
],
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@babel/core": "^7.26.0",
|
|
37
|
-
"@reticlehq/babel-plugin": "2.
|
|
38
|
-
"@reticlehq/core": "2.
|
|
37
|
+
"@reticlehq/babel-plugin": "2.4.0",
|
|
38
|
+
"@reticlehq/core": "2.4.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@types/babel__core": "^7.20.5",
|
|
42
|
+
"svelte": "^5.56.8",
|
|
42
43
|
"vite": "^8"
|
|
43
44
|
},
|
|
44
45
|
"peerDependencies": {
|