@saywright/vite 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,478 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { parse } from "@babel/parser";
5
+ import MagicString from "magic-string";
6
+ /** Attribute names injected by the Vite plugin at compile time. */
7
+ var SRC_ATTR = "data-ds-src";
8
+ var NAME_ATTR = "data-ds-name";
9
+ //#endregion
10
+ //#region src/annotate.ts
11
+ /**
12
+ * Compile-time JSX annotation. Every JSX element gets
13
+ * data-ds-src="relative/file.tsx:line:col" data-ds-name="Component"
14
+ * inserted right after its tag name, before any other attribute. Because the
15
+ * attributes come first, a component that spreads its props onto its root DOM
16
+ * node (`<button {...rest}>`) ends up showing the *instance* site, which is
17
+ * where a prop change belongs.
18
+ */
19
+ var SKIP_COMPONENTS = /* @__PURE__ */ new Set([
20
+ "Fragment",
21
+ "React.Fragment",
22
+ "StrictMode",
23
+ "React.StrictMode",
24
+ "Suspense",
25
+ "React.Suspense",
26
+ "Profiler",
27
+ "React.Profiler",
28
+ "Activity",
29
+ "React.Activity"
30
+ ]);
31
+ /** SVG primitives are noise for pointing; the <svg> root is still annotated. */
32
+ var SVG_INNER = /* @__PURE__ */ new Set([
33
+ "path",
34
+ "g",
35
+ "circle",
36
+ "rect",
37
+ "line",
38
+ "polyline",
39
+ "polygon",
40
+ "ellipse",
41
+ "defs",
42
+ "use",
43
+ "lineargradient",
44
+ "radialgradient",
45
+ "stop",
46
+ "clippath",
47
+ "mask",
48
+ "pattern",
49
+ "symbol",
50
+ "text",
51
+ "tspan",
52
+ "filter",
53
+ "title",
54
+ "desc",
55
+ "marker",
56
+ "foreignobject"
57
+ ]);
58
+ var SKIP_KEYS = /* @__PURE__ */ new Set([
59
+ "loc",
60
+ "start",
61
+ "end",
62
+ "extra",
63
+ "range",
64
+ "comments",
65
+ "leadingComments",
66
+ "trailingComments",
67
+ "innerComments",
68
+ "tokens"
69
+ ]);
70
+ function walk(node, visit) {
71
+ if (!node || typeof node !== "object") return;
72
+ if (Array.isArray(node)) {
73
+ for (const child of node) walk(child, visit);
74
+ return;
75
+ }
76
+ const n = node;
77
+ if (typeof n.type === "string") visit(n);
78
+ for (const key of Object.keys(n)) {
79
+ if (SKIP_KEYS.has(key)) continue;
80
+ const value = n[key];
81
+ if (value && typeof value === "object") walk(value, visit);
82
+ }
83
+ }
84
+ function jsxName(name) {
85
+ if (!name) return null;
86
+ if (name.type === "JSXIdentifier") return String(name.name);
87
+ if (name.type === "JSXMemberExpression") {
88
+ const object = jsxName(name.object);
89
+ const property = jsxName(name.property);
90
+ return object && property ? `${object}.${property}` : null;
91
+ }
92
+ return null;
93
+ }
94
+ function shouldAnnotate(name) {
95
+ if (SKIP_COMPONENTS.has(name)) return false;
96
+ if (name.endsWith(".Provider") || name.endsWith(".Consumer")) return false;
97
+ if (SVG_INNER.has(name.toLowerCase()) && name === name.toLowerCase()) return false;
98
+ return true;
99
+ }
100
+ function annotate(code, file, relPath) {
101
+ if (!code.includes("<")) return null;
102
+ let ast;
103
+ try {
104
+ ast = parse(code, {
105
+ sourceType: "module",
106
+ plugins: ["jsx", "typescript"],
107
+ errorRecovery: true
108
+ });
109
+ } catch {
110
+ return null;
111
+ }
112
+ const s = new MagicString(code);
113
+ let count = 0;
114
+ const safePath = relPath.replace(/"/g, "");
115
+ walk(ast, (node) => {
116
+ if (node.type !== "JSXOpeningElement") return;
117
+ const nameNode = node.name;
118
+ const name = jsxName(nameNode);
119
+ if (!name || !shouldAnnotate(name)) return;
120
+ if ((node.attributes ?? []).some((a) => a.type === "JSXAttribute" && a.name?.type === "JSXIdentifier" && a.name.name === "data-ds-src")) return;
121
+ const loc = node.loc;
122
+ if (!loc) return;
123
+ const insertAt = (node.typeArguments ?? node.typeParameters)?.end ?? nameNode.end;
124
+ if (typeof insertAt !== "number") return;
125
+ s.appendLeft(insertAt, ` ${SRC_ATTR}="${safePath}:${loc.start.line}:${loc.start.column + 1}" ${NAME_ATTR}="${name}"`);
126
+ count++;
127
+ });
128
+ if (!count) return null;
129
+ return {
130
+ code: s.toString(),
131
+ map: s.generateMap({
132
+ hires: "boundary",
133
+ source: file,
134
+ includeContent: true
135
+ }),
136
+ count
137
+ };
138
+ }
139
+ //#endregion
140
+ //#region src/batch.ts
141
+ /**
142
+ * Changes that land at once. The studio writes a change's files one after another, and the dev server would send the
143
+ * page an update for each as it sees them: a page that runs the first file's new code with the second's old code can
144
+ * break ("Cannot read properties of undefined"). While the studio writes several files it asks for the page's updates
145
+ * to be held; once every file it named has been seen (or after a few seconds, if it never finishes), they are sent as
146
+ * one update, which the page applies all together.
147
+ */
148
+ /** Held at most this long: a studio that stopped halfway doesn't leave the page without updates. */
149
+ var HOLD_MS = 4e3;
150
+ /** Once every file has been seen, how long the dev server must have been quiet before the updates go. */
151
+ var SETTLE_MS = 60;
152
+ /** One spelling for a file, however it was named (a link in the path, Windows' separators). */
153
+ function same(file) {
154
+ const abs = path.resolve(file);
155
+ try {
156
+ return fs.realpathSync.native(abs);
157
+ } catch {
158
+ try {
159
+ return path.join(fs.realpathSync.native(path.dirname(abs)), path.basename(abs));
160
+ } catch {
161
+ return abs;
162
+ }
163
+ }
164
+ }
165
+ var UpdateHold = class {
166
+ held = [];
167
+ waiting = /* @__PURE__ */ new Set();
168
+ holding = false;
169
+ lastHeld = 0;
170
+ limit = null;
171
+ settle = null;
172
+ send;
173
+ constructor(server) {
174
+ const ws = server.ws;
175
+ const original = ws.send;
176
+ this.send = (payload) => original.call(ws, payload);
177
+ ws.send = (...args) => {
178
+ const payload = typeof args[0] === "string" ? {
179
+ type: "custom",
180
+ event: args[0],
181
+ data: args[1]
182
+ } : args[0];
183
+ if (this.holding && [
184
+ "update",
185
+ "full-reload",
186
+ "prune",
187
+ "error"
188
+ ].includes(payload?.type)) {
189
+ this.held.push(payload);
190
+ this.lastHeld = Date.now();
191
+ return;
192
+ }
193
+ original.apply(ws, args);
194
+ };
195
+ for (const event of [
196
+ "add",
197
+ "change",
198
+ "unlink"
199
+ ]) server.watcher.on(event, (file) => this.seen(file));
200
+ }
201
+ /** Hold the page's updates until these files have all changed (or `ms` have passed). */
202
+ hold(files, ms = HOLD_MS) {
203
+ for (const f of files) this.waiting.add(same(f));
204
+ this.holding = true;
205
+ if (this.limit) clearTimeout(this.limit);
206
+ this.limit = setTimeout(() => this.release(), ms);
207
+ if (!this.waiting.size) this.settled();
208
+ }
209
+ seen(file) {
210
+ if (!this.holding || !this.waiting.delete(same(file))) return;
211
+ if (!this.waiting.size) this.settled();
212
+ }
213
+ /** Every file has been seen: the dev server works out each one's update in a moment, then they go. */
214
+ settled() {
215
+ if (this.settle) clearTimeout(this.settle);
216
+ this.settle = setTimeout(() => {
217
+ this.settle = null;
218
+ if (Date.now() - this.lastHeld < SETTLE_MS) this.settled();
219
+ else this.release();
220
+ }, SETTLE_MS);
221
+ }
222
+ /** Send what was held, as one: a reload if any file needs one, else every update together. */
223
+ release() {
224
+ if (this.limit) clearTimeout(this.limit);
225
+ if (this.settle) clearTimeout(this.settle);
226
+ this.limit = this.settle = null;
227
+ this.holding = false;
228
+ this.waiting.clear();
229
+ const held = this.held;
230
+ this.held = [];
231
+ for (const payload of merged(held)) this.send(payload);
232
+ }
233
+ };
234
+ /** Held updates as the page should get them: at most one reload, or one update with all of them, then the rest in order. */
235
+ function merged(held) {
236
+ const reloads = held.filter((p) => p.type === "full-reload");
237
+ if (reloads.length) return [reloads.length === 1 ? reloads[0] : {
238
+ type: "full-reload",
239
+ path: "*"
240
+ }];
241
+ const updates = /* @__PURE__ */ new Map();
242
+ for (const p of held) for (const u of p.updates ?? []) updates.set(`${u.type}\n${u.path}\n${u.acceptedPath}`, u);
243
+ const rest = held.filter((p) => p.type !== "update");
244
+ return [...updates.size ? [{
245
+ type: "update",
246
+ updates: [...updates.values()]
247
+ }] : [], ...rest];
248
+ }
249
+ //#endregion
250
+ //#region src/workspace.ts
251
+ /**
252
+ * The workspace (monorepo) an app belongs to. npm, yarn and pnpm workspaces, Lerna, Nx, Turborepo and Rush keep an
253
+ * app's components in sibling packages (apps/web next to packages/ui), so the plugin annotates those files too and
254
+ * the studio reads and edits them, by paths relative to the app's folder ("../packages/ui/Button.tsx").
255
+ */
256
+ /** Files that make a folder a workspace root, with the package folders they list (null: every folder below). */
257
+ function members(dir) {
258
+ const ws = readJson(path.join(dir, "package.json"))?.workspaces;
259
+ if (Array.isArray(ws)) return ws;
260
+ if (ws && Array.isArray(ws.packages)) return ws.packages;
261
+ const pnpm = read(path.join(dir, "pnpm-workspace.yaml"));
262
+ if (pnpm !== null) return yamlList(pnpm, "packages");
263
+ const lerna = readJson(path.join(dir, "lerna.json"));
264
+ if (lerna) return Array.isArray(lerna.packages) ? lerna.packages : ["packages/*"];
265
+ if ([
266
+ "nx.json",
267
+ "turbo.json",
268
+ "rush.json"
269
+ ].some((f) => fs.existsSync(path.join(dir, f)))) return null;
270
+ }
271
+ function read(file) {
272
+ try {
273
+ return fs.readFileSync(file, "utf8");
274
+ } catch {
275
+ return null;
276
+ }
277
+ }
278
+ function readJson(file) {
279
+ const text = read(file);
280
+ if (text === null) return null;
281
+ try {
282
+ return JSON.parse(text);
283
+ } catch {
284
+ return null;
285
+ }
286
+ }
287
+ /** The items of a top-level list in a small YAML file ("packages:\n - 'apps/*'"). */
288
+ function yamlList(text, key) {
289
+ const out = [];
290
+ let inList = false;
291
+ for (const line of text.split(/\r?\n/)) if (new RegExp(`^${key}\\s*:`).test(line)) inList = true;
292
+ else if (inList && /^\s*-\s*/.test(line)) out.push(line.replace(/^\s*-\s*/, "").replace(/\s+#.*$/, "").trim().replace(/^['"]|['"]$/g, ""));
293
+ else if (inList && /^\S/.test(line)) break;
294
+ return out;
295
+ }
296
+ /** A workspace's package pattern ("packages/*", "apps/**") → a test on a folder path relative to the workspace. */
297
+ function globRe(glob) {
298
+ const body = glob.replace(/^\.\//, "").replace(/\/+$/, "").split(/(\*\*\/?|\*)/).map((part) => part === "**/" ? "(?:.*/)?" : part === "**" ? ".*" : part === "*" ? "[^/]*" : part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join("");
299
+ return new RegExp(`^${body}$`);
300
+ }
301
+ /** Whether a package folder (relative to the workspace) is one the workspace lists. */
302
+ function isMember(rel, globs) {
303
+ const included = globs.filter((g) => !g.startsWith("!"));
304
+ const excluded = globs.filter((g) => g.startsWith("!")).map((g) => globRe(g.slice(1)));
305
+ return included.some((g) => globRe(g).test(rel)) && !excluded.some((re) => re.test(rel));
306
+ }
307
+ /**
308
+ * The root of the workspace whose package `dir` is in (or `dir` itself when it is the root), or null when it is in
309
+ * none. A package below a workspace root that the workspace doesn't list (a copy, a fixture) is in none.
310
+ */
311
+ function workspaceRoot(dir) {
312
+ const start = path.resolve(dir);
313
+ let pkg = null;
314
+ for (let cur = start;;) {
315
+ pkg ??= fs.existsSync(path.join(cur, "package.json")) ? cur : null;
316
+ const globs = members(cur);
317
+ if (globs !== void 0) {
318
+ if (cur === start || cur === pkg) return cur;
319
+ const rel = path.relative(cur, pkg ?? start).split(path.sep).join("/");
320
+ return globs === null || isMember(rel, globs) ? cur : null;
321
+ }
322
+ const up = path.dirname(cur);
323
+ if (up === cur) return null;
324
+ cur = up;
325
+ }
326
+ }
327
+ /** Whether `file` is inside `dir` (both absolute). */
328
+ function isInside(dir, file) {
329
+ const rel = path.relative(dir, file);
330
+ return !!rel && !rel.startsWith("..") && !path.isAbsolute(rel);
331
+ }
332
+ //#endregion
333
+ //#region src/index.ts
334
+ /**
335
+ * @saywright/vite — add to a Vite + React app to make it pointable from
336
+ * Saywright. Dev server only; production builds are untouched.
337
+ *
338
+ * import saywright from '@saywright/vite';
339
+ * export default defineConfig({ plugins: [saywright(), react()] });
340
+ */
341
+ var cached = null;
342
+ /** The bridge script, read again when it has been rebuilt (updating Saywright doesn't need an app restart). */
343
+ function readBridge() {
344
+ const here = path.dirname(fileURLToPath(import.meta.url));
345
+ for (const file of [path.join(here, "bridge.js"), path.join(here, "../dist/bridge.js")]) {
346
+ let mtime;
347
+ try {
348
+ mtime = fs.statSync(file).mtimeMs;
349
+ } catch {
350
+ continue;
351
+ }
352
+ if (cached?.file !== file || cached.mtime !== mtime) cached = {
353
+ file,
354
+ mtime,
355
+ code: fs.readFileSync(file, "utf8")
356
+ };
357
+ return cached.code;
358
+ }
359
+ return "console.warn(\"[saywright] bridge.js missing: run `npm run build:plugin`.\")";
360
+ }
361
+ function saywright(options = {}) {
362
+ const include = options.include ?? /\.[jt]sx$/;
363
+ const exclude = options.exclude ?? /[\\/]node_modules[\\/]/;
364
+ let root = process.cwd();
365
+ let scope = root;
366
+ let studio = options.studio ?? process.env.SAYWRIGHT_STUDIO_URL ?? null;
367
+ return {
368
+ name: "saywright",
369
+ apply: "serve",
370
+ enforce: "pre",
371
+ configResolved(config) {
372
+ root = config.root;
373
+ scope = workspaceRoot(root) ?? root;
374
+ },
375
+ transform(code, id) {
376
+ const file = id.split("?")[0];
377
+ if (!include.test(file) || exclude.test(file) || /[\\/]node_modules[\\/]/.test(file) || file.startsWith("\0")) return null;
378
+ const rel = path.relative(root, file).split(path.sep).join("/");
379
+ if (!rel || path.isAbsolute(rel) || !isInside(scope, file)) return null;
380
+ const out = annotate(code, file, rel);
381
+ return out ? {
382
+ code: out.code,
383
+ map: out.map
384
+ } : null;
385
+ },
386
+ transformIndexHtml() {
387
+ return [{
388
+ tag: "script",
389
+ children: `window.__SAYWRIGHT__=${JSON.stringify({
390
+ origins: options.studioOrigins ?? [],
391
+ studio,
392
+ widget: options.widget ?? true
393
+ })}`,
394
+ injectTo: "head-prepend"
395
+ }, {
396
+ tag: "script",
397
+ attrs: {
398
+ type: "module",
399
+ src: "/__saywright/bridge.js"
400
+ },
401
+ injectTo: "head-prepend"
402
+ }];
403
+ },
404
+ configureServer(server) {
405
+ server.middlewares.use("/__saywright/bridge.js", (_req, res) => {
406
+ res.setHeader("Content-Type", "text/javascript; charset=utf-8");
407
+ res.setHeader("Cache-Control", "no-cache");
408
+ res.end(readBridge());
409
+ });
410
+ server.middlewares.use("/__saywright/info", (_req, res) => {
411
+ res.setHeader("Content-Type", "application/json");
412
+ res.end(JSON.stringify({
413
+ root,
414
+ version: 1,
415
+ studio
416
+ }));
417
+ });
418
+ server.middlewares.use("/__saywright/studio", (req, res) => {
419
+ if (req.method !== "POST") {
420
+ res.statusCode = 405;
421
+ return res.end();
422
+ }
423
+ if (req.headers.origin) {
424
+ res.statusCode = 403;
425
+ return res.end();
426
+ }
427
+ let body = "";
428
+ req.on("data", (c) => (body += c.toString()).length > 2e3 && req.destroy());
429
+ req.on("end", () => {
430
+ try {
431
+ const url = new URL(JSON.parse(body).url);
432
+ if (!/^https?:$/.test(url.protocol) || !/^(localhost|127\.0\.0\.1|\[::1\])$/.test(url.hostname)) throw new Error("local only");
433
+ if (!options.studio) studio = url.origin;
434
+ res.setHeader("Content-Type", "application/json");
435
+ res.end(JSON.stringify({ ok: true }));
436
+ } catch {
437
+ res.statusCode = 400;
438
+ res.end();
439
+ }
440
+ });
441
+ });
442
+ let hold = null;
443
+ server.middlewares.use("/__saywright/hold", (req, res) => {
444
+ if (req.method !== "POST") {
445
+ res.statusCode = 405;
446
+ return res.end();
447
+ }
448
+ if (req.headers.origin) {
449
+ res.statusCode = 403;
450
+ return res.end();
451
+ }
452
+ let body = "";
453
+ req.on("data", (c) => (body += c.toString()).length > 2e5 && req.destroy());
454
+ req.on("end", () => {
455
+ try {
456
+ const files = JSON.parse(body).files;
457
+ if (!Array.isArray(files) || !files.length || files.length > 500 || !files.every((f) => typeof f === "string" && path.isAbsolute(f))) throw new Error("files");
458
+ const ours = files.filter((f) => isInside(scope, f));
459
+ if (ours.length) {
460
+ hold ??= new UpdateHold(server);
461
+ hold.hold(ours);
462
+ }
463
+ res.setHeader("Content-Type", "application/json");
464
+ res.end(JSON.stringify({
465
+ ok: true,
466
+ held: ours.length > 0
467
+ }));
468
+ } catch {
469
+ res.statusCode = 400;
470
+ res.end();
471
+ }
472
+ });
473
+ });
474
+ }
475
+ };
476
+ }
477
+ //#endregion
478
+ export { saywright as default, saywright };