@uniflowed/vite 0.0.0-alpha.1 → 0.0.0-alpha.11

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/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ // @noflow
2
+ //
1
3
  // Plain JavaScript: Vite imports this module directly, before any transform.
2
4
  //
3
5
  // `@uniflowed/vite` — uf, as Vite plugins.
@@ -14,9 +16,21 @@
14
16
  // that hydrates it, and the server entry that renders it. In
15
17
  // development it also renders every HTML request on the
16
18
  // server, so `uf dev` serves the same markup `uf build` writes.
19
+ // The client's copy of the route table is not the server's:
20
+ // `internal/rsc.js` reads the RSC analysis and leaves out the
21
+ // page of every route no client boundary reaches, so that
22
+ // route's modules never enter the browser bundle.
17
23
  // * `uf:mdx` — `@mdx-js/rollup`, configured for React with GitHub-flavoured
18
- // markdown, front matter and heading ids, so `.mdx` works with
24
+ // markdown, front matter, heading ids and build-time syntax
25
+ // highlighting, so `.mdx` works with
19
26
  // no configuration.
27
+ // * `uf:asset` — an imported image is decoded, resized to the widths the
28
+ // project declares and re-encoded by `uf assets`, and an
29
+ // imported font is self-hosted with the `@font-face` and the
30
+ // metric-matched fallback that stop the swap moving the page.
31
+ // The import evaluates to what `Image` and `Font` need — the
32
+ // intrinsic size, every emitted variant, the placeholder —
33
+ // rather than to a URL string. See `internal/assets.js`.
20
34
  //
21
35
  // `uniflowed(options)` returns the array; a project that wants to add a plugin
22
36
  // declares it in `uf.config.js` and the driver appends it after these.
@@ -26,6 +40,10 @@ import path from "node:path";
26
40
 
27
41
  import mdx from "@mdx-js/rollup";
28
42
  import rehypeSlug from "rehype-slug";
43
+
44
+ import { assetPlugin } from "./internal/assets.js";
45
+ import { emit, reportRenderError } from "./internal/events.js";
46
+ import { highlightPlugin } from "./internal/highlight.js";
29
47
  import remarkFrontmatter from "remark-frontmatter";
30
48
  import remarkGfm from "remark-gfm";
31
49
  import remarkMdxFrontmatter from "remark-mdx-frontmatter";
@@ -37,19 +55,33 @@ import {
37
55
  preambleCode,
38
56
  refreshRuntimeSource,
39
57
  } from "./internal/refresh.js";
58
+ import { RSC_MANIFEST_ENV, clientRouteFilter, readRscManifest } from "./internal/rsc.js";
40
59
  import {
60
+ RESERVED,
41
61
  VIRTUAL,
42
62
  clientModuleSource,
43
63
  routesModuleSource,
44
64
  scanRoutes,
45
65
  serverModuleSource,
46
66
  } from "./internal/routes.js";
47
- import { TransformService, isFlowModule } from "./transform.js";
67
+ import { TransformService, isFlowModule } from "@uniflowed/host/transform";
68
+ import { send, toRequest } from "./internal/http.js";
69
+ import { withRequest } from "./internal/serve.js";
48
70
 
49
71
  /** A resolved virtual id: Vite's convention is a leading NUL byte. */
50
72
  const resolved = (id) => `\0${id}`;
51
73
  const VIRTUAL_IDS = new Set(Object.values(VIRTUAL));
52
74
 
75
+ /**
76
+ * Prefix of the virtual module that carries one source module's StyleX rules.
77
+ *
78
+ * Not NUL-prefixed, unlike the virtual modules above: Vite's CSS pipeline keys
79
+ * off the `.css` extension of a *resolvable* id, and a NUL-prefixed id is
80
+ * excluded from it. The prefix is distinctive enough that nothing else can
81
+ * collide with it.
82
+ */
83
+ const STYLE_PREFIX = "uf-style:";
84
+
53
85
  /** The URL a NUL-prefixed module is served at in development. */
54
86
  export function devUrlFor(id) {
55
87
  return `/@id/__x00__${id}`;
@@ -75,8 +107,17 @@ export default function uniflowed(options = {}) {
75
107
  const routerRoot = app.router?.root ?? "app";
76
108
  const appEntry = app.router?.entry ?? ufConfig.build?.entries?.[0] ?? "app.js";
77
109
  const markdown = app.builtins?.markdown ?? {};
110
+ const builtins = app.builtins ?? {};
78
111
 
79
- return [flowPlugin({ routerRoot, appEntry, command: options.command }), mdxPlugin(markdown)];
112
+ return [
113
+ flowPlugin({ routerRoot, appEntry, command: options.command }),
114
+ mdxPlugin(markdown),
115
+ assetPlugin({
116
+ images: builtins.images ?? {},
117
+ fonts: builtins.fonts ?? {},
118
+ command: options.command,
119
+ }),
120
+ ];
80
121
  }
81
122
 
82
123
  function flowPlugin({ routerRoot, appEntry, command }) {
@@ -89,12 +130,64 @@ function flowPlugin({ routerRoot, appEntry, command }) {
89
130
  let server = null;
90
131
  /** @type {TransformService | null} */
91
132
  let service = null;
133
+ /**
134
+ * Each module's compiled stylesheet, keyed by the virtual id serving it.
135
+ *
136
+ * A map rather than one accumulated sheet: Vite asks for a module's CSS when
137
+ * it loads that module, re-asks when the module changes, and drops it when
138
+ * the module goes away. One shared sheet would have to be invalidated by
139
+ * hand, which is the part that goes wrong.
140
+ */
141
+ const styles = new Map();
142
+ /**
143
+ * The React Compiler findings already reported, so each is said once.
144
+ *
145
+ * `uf build` runs Vite twice — once for the browser bundle and once for the
146
+ * server one — over the same modules, so every finding was made twice and
147
+ * printed twice. An entry records which environment reported a module's
148
+ * findings first: a re-transform in *that* environment (a dev server, after
149
+ * an edit) clears it and reports again, and the other environment's pass over
150
+ * the same module stays quiet. Keying on the environment rather than on a
151
+ * flag is what keeps the second half true without making the first half
152
+ * false.
153
+ *
154
+ * @type {Map<string, { environment: string, signatures: Set<string> }>}
155
+ */
156
+ const reported = new Map();
157
+ /** Findings held back as a dependency's, waiting to be counted out loud. */
158
+ let suppressed = [];
92
159
 
93
160
  const ensureService = () => {
94
161
  service ??= new TransformService({ command, root });
95
162
  return service;
96
163
  };
97
164
 
165
+ /**
166
+ * The browser's copy of the route table.
167
+ *
168
+ * The manifest is read here rather than once at start-up because `uf dev`
169
+ * rewrites it whenever the graph moves, and this hook runs again when it
170
+ * does — a table built from a manifest read at start-up would be the answer
171
+ * for the project as it was when the server started.
172
+ *
173
+ * The count is emitted rather than computed on the Rust side, and that is
174
+ * the point of it: `uf build` prints what the table it just generated
175
+ * contains, not what a second implementation of this decision predicted it
176
+ * would. Only for a build — a dev server has no summary to be true in.
177
+ */
178
+ const clientRoutesModule = (table) => {
179
+ const shipsPage = clientRouteFilter(
180
+ readRscManifest(process.env[RSC_MANIFEST_ENV]),
181
+ root,
182
+ table,
183
+ );
184
+ const kept = new Set(table.routes.filter(shipsPage));
185
+ if (server == null) {
186
+ emit("rsc-split", { pages: kept.size, routes: table.routes.length });
187
+ }
188
+ return routesModuleSource(table, { shipsPage: (route) => kept.has(route) });
189
+ };
190
+
98
191
  return {
99
192
  name: "uf:flow",
100
193
  enforce: "pre",
@@ -144,14 +237,26 @@ function flowPlugin({ routerRoot, appEntry, command }) {
144
237
  resolveId(id) {
145
238
  if (id === RUNTIME_PUBLIC_PATH) return RUNTIME_RESOLVED_ID;
146
239
  if (VIRTUAL_IDS.has(id)) return resolved(id);
240
+ // A module's own stylesheet, which `transform` below asked for by
241
+ // importing this id. Returning it unchanged marks it resolved without
242
+ // Vite going to the filesystem for a file that does not exist.
243
+ if (id.startsWith(STYLE_PREFIX)) return id;
147
244
  return null;
148
245
  },
149
246
 
150
- load(id) {
247
+ load(id, loadOptions) {
151
248
  if (id === RUNTIME_RESOLVED_ID) return refreshRuntimeSource();
152
- if (id === resolved(VIRTUAL.routes)) return routesModuleSource(scanRoutes(appRoot));
249
+ if (id === resolved(VIRTUAL.routes)) {
250
+ const table = scanRoutes(appRoot);
251
+ // The server renders every route, so the server's table is the whole
252
+ // one and is generated with no filter at all. Only the browser's copy
253
+ // is split.
254
+ if (isSsr(this, loadOptions)) return routesModuleSource(table);
255
+ return clientRoutesModule(table);
256
+ }
153
257
  if (id === resolved(VIRTUAL.client)) return clientModuleSource(entryPath);
154
258
  if (id === resolved(VIRTUAL.server)) return serverModuleSource(entryPath);
259
+ if (id.startsWith(STYLE_PREFIX)) return styles.get(id) ?? "";
155
260
  return null;
156
261
  },
157
262
 
@@ -165,16 +270,52 @@ function flowPlugin({ routerRoot, appEntry, command }) {
165
270
  sourceMap: true,
166
271
  });
167
272
  if (out == null) return null;
168
- for (const diagnostic of out.diagnostics) {
169
- this.warn?.(`${diagnostic.function ?? "a function"}: ${diagnostic.message}`);
170
- }
273
+ reportDiagnostics(this, {
274
+ id: cleanId(id),
275
+ root,
276
+ diagnostics: out.diagnostics,
277
+ environment: ssr ? "ssr" : "client",
278
+ reported,
279
+ suppressed,
280
+ });
171
281
  const map = out.map == null ? null : JSON.parse(out.map);
172
- if (!refresh) return { code: out.code, map };
282
+ // StyleX. `uf transform` compiled the module's `stylex.create` calls into
283
+ // class names and handed back the rules they declared; the rules become a
284
+ // module of their own that this one imports.
285
+ //
286
+ // Handing the CSS to Vite as a module, rather than collecting it here and
287
+ // writing a stylesheet at the end, is what keeps uf out of the CSS
288
+ // business: Vite already injects a stylesheet in dev, extracts it in a
289
+ // build, code-splits it per chunk, and replaces it over HMR. A module
290
+ // whose styles are gone stops importing it, and Vite notices.
291
+ const styled = out.css != null && out.css !== "";
292
+ let output = out.code;
293
+ if (styled) {
294
+ const styleId = `${STYLE_PREFIX}${cleanId(id)}.css`;
295
+ styles.set(styleId, out.css);
296
+ output = `import ${JSON.stringify(styleId)};\n${output}`;
297
+ }
298
+ // A module that compiled a stylesheet has a side effect, whatever its
299
+ // package says. `@uniflowed/stylex` declares `sideEffects: false` and is
300
+ // right about its source: `tokens.stylex.js` only exports a token set.
301
+ // What it exports after this transform is a token set *and* a `:root`
302
+ // block, and the page that imports `ufTokens` no longer names it at
303
+ // runtime — the compiler turned every read into the `var(--…)` it minted.
304
+ // So the import was unused, a side-effect-free module with no used
305
+ // exports was dropped, and the custom properties every one of those
306
+ // `var()`s resolves against went with it: rules that referred to nothing.
307
+ // Declaring the side effect here rather than editing the package is
308
+ // deliberate — the side effect is one this plugin added, so it is this
309
+ // plugin's to admit to. See ubugeeei-prod/uf#306.
310
+ const moduleSideEffects = styled ? true : undefined;
311
+ if (!refresh) return { code: output, map, moduleSideEffects };
173
312
  const relative = path.relative(root, cleanId(id)).split(path.sep).join("/");
174
- return addRefreshWrapper(out.code, map, relative);
313
+ return { ...addRefreshWrapper(output, map, relative), moduleSideEffects };
175
314
  },
176
315
 
177
316
  buildEnd() {
317
+ summariseSuppressed(this, suppressed);
318
+ suppressed = [];
178
319
  // A dev server keeps its service for the whole session; a build is
179
320
  // done with it here.
180
321
  if (server == null) {
@@ -202,9 +343,19 @@ function flowPlugin({ routerRoot, appEntry, command }) {
202
343
  service = null;
203
344
  });
204
345
 
205
- // A page or layout appearing or disappearing changes the route table,
346
+ // A reserved file appearing or disappearing changes the route table,
206
347
  // which lives in a virtual module the watcher knows nothing about.
207
- const reserved = /\/_uf\.(page|layout|middleware|not-found)(\.[a-z]+)?\.(js|jsx|mdx)$/;
348
+ //
349
+ // Built from `RESERVED` rather than written out. It used to be the
350
+ // literal `(page|layout|middleware|not-found)`, which is a fourth
351
+ // spelling of a grammar that already has three, and it was already
352
+ // missing `route` — so adding a route handler to a running dev server
353
+ // did not rebuild the table and the handler stayed invisible until a
354
+ // restart. A list that has to match another list has to be that list.
355
+ const stems = Object.values(RESERVED)
356
+ .map((stem) => stem.replaceAll(".", "\\."))
357
+ .join("|");
358
+ const reserved = new RegExp(`/(${stems})(\\.[a-z]+)?\\.(js|jsx|mdx)$`);
208
359
  const onRouteFile = (file) => {
209
360
  if (!reserved.test(file) || !file.startsWith(appRoot)) return;
210
361
  const routes = devServer.moduleGraph.getModuleById(resolved(VIRTUAL.routes));
@@ -214,6 +365,27 @@ function flowPlugin({ routerRoot, appEntry, command }) {
214
365
  devServer.watcher.on("add", onRouteFile);
215
366
  devServer.watcher.on("unlink", onRouteFile);
216
367
 
368
+ // The same problem one level up. Adding `"use client"` to a module, or
369
+ // deleting the import that reached it, changes which routes the browser
370
+ // is given a page for — and touches no reserved file name, so nothing
371
+ // above notices. `uf dev` rewrites the RSC manifest when the analysis
372
+ // moves and only then, so this fires when the answer changed rather than
373
+ // on every keystroke. Watched explicitly because the file is uf's own
374
+ // artefact and is in no module graph.
375
+ const manifestFile = process.env[RSC_MANIFEST_ENV];
376
+ if (manifestFile != null && manifestFile !== "") {
377
+ const manifestPath = path.resolve(manifestFile);
378
+ devServer.watcher.add(manifestPath);
379
+ const onManifest = (file) => {
380
+ if (path.resolve(file) !== manifestPath) return;
381
+ const routes = devServer.moduleGraph.getModuleById(resolved(VIRTUAL.routes));
382
+ if (routes) devServer.moduleGraph.invalidateModule(routes);
383
+ devServer.ws.send({ type: "full-reload", path: "*" });
384
+ };
385
+ devServer.watcher.on("add", onManifest);
386
+ devServer.watcher.on("change", onManifest);
387
+ }
388
+
217
389
  // After Vite's own middlewares, so `/@vite/client`, `/@id/...` and
218
390
  // static files are served first and only a document request reaches
219
391
  // the renderer.
@@ -222,19 +394,66 @@ function flowPlugin({ routerRoot, appEntry, command }) {
222
394
  if (!wantsDocument(request)) return next();
223
395
  try {
224
396
  const url = request.url ?? "/";
225
- const { render } = await importServerEntry(devServer);
226
- const result = await render(url, {
227
- scripts: [devUrlFor(VIRTUAL.client)],
228
- styles: [],
229
- preloads: [],
397
+ const entry = await importServerEntry(devServer);
398
+ const asRequest = await toRequest(request, devServer.config);
399
+
400
+ // One request, owned here and settled once the document has been
401
+ // written — the same lifecycle `driver.js` gives `uf dev` and
402
+ // `internal/serve.js` gives `uf preview` and `uf start`. A project
403
+ // driving Vite itself must not get a different answer about when
404
+ // `after()` runs than the same project run through `uf dev`; see
405
+ // `internal/serve.js` and ubugeeei-prod/uf#389.
406
+ //
407
+ // Only requests that look like a document reach here, so unlike
408
+ // `driver.js` there is no path where uf hands the response back to
409
+ // Vite's chain: what is below either writes it or throws.
410
+ await withRequest(entry, asRequest, async () => {
411
+ // Before anything answers: a middleware guards a subtree, and a
412
+ // page rendered while the guard on it had not run is the whole of
413
+ // ubugeeei-prod/uf#260. `driver.js` makes the same call, for
414
+ // every method.
415
+ const guarded = await entry.runMiddleware(asRequest);
416
+ if (guarded != null) {
417
+ await send(response, guarded);
418
+ return;
419
+ }
420
+
421
+ // Then the route handlers, above the renderer and for the same
422
+ // reason `driver.js` puts them there: a path that answers a
423
+ // request is not a document, whatever the client said it would
424
+ // accept. `curl /api/thing` and a `<form action>` navigation both
425
+ // send `Accept: text/html`, and both want the handler's answer.
426
+ //
427
+ // This step is not a duplicate of the dispatcher in `driver.js`,
428
+ // it is the only one that can run: this middleware is mounted by
429
+ // `configureServer`, which Vite calls while it is building the
430
+ // server, and `uf dev` adds its own after `createServer` has
431
+ // returned — so for every request this one claims, it is the one
432
+ // that decides. Without it a route handler under `uf dev` was
433
+ // reachable only by a client that asked for something other than
434
+ // HTML, and answered the 404 page to everyone else.
435
+ const handled = await entry.dispatch(asRequest);
436
+ if (handled != null) {
437
+ await send(response, handled);
438
+ return;
439
+ }
440
+
441
+ const result = await entry.render(
442
+ url,
443
+ { scripts: [devUrlFor(VIRTUAL.client)], styles: [], preloads: [] },
444
+ { onError: (error) => reportRenderError(devServer, url, error) },
445
+ );
446
+ if (result.error != null) reportRenderError(devServer, url, result.error);
447
+ // Collected rather than piped, for the reason `driver.js` gives at
448
+ // step 4: `transformIndexHtml` is a whole-document hook.
449
+ const html = await devServer.transformIndexHtml(url, await result.text());
450
+ response.statusCode = result.status;
451
+ response.setHeader("Content-Type", "text/html; charset=utf-8");
452
+ for (const [name, value] of Object.entries(result.headers ?? {})) {
453
+ response.setHeader(name, value);
454
+ }
455
+ response.end(html);
230
456
  });
231
- const html = await devServer.transformIndexHtml(url, result.html);
232
- response.statusCode = result.status;
233
- response.setHeader("Content-Type", "text/html; charset=utf-8");
234
- for (const [name, value] of Object.entries(result.headers ?? {})) {
235
- response.setHeader(name, value);
236
- }
237
- response.end(html);
238
457
  } catch (error) {
239
458
  devServer.ssrFixStacktrace(error);
240
459
  next(error);
@@ -248,12 +467,23 @@ function flowPlugin({ routerRoot, appEntry, command }) {
248
467
  function mdxPlugin(markdown) {
249
468
  const mdxConfig = markdown.mdx ?? {};
250
469
  if (mdxConfig.enabled === false) return { name: "uf:mdx" };
470
+
471
+ // Highlighting is on unless a project turns it off, and it happens here
472
+ // rather than in the browser: the colours are in the HTML, so a code sample
473
+ // is readable before any JavaScript loads and no highlighter is shipped.
474
+ const highlight = highlightPlugin(mdxConfig.highlight);
475
+ const rehypePlugins = highlight == null ? [rehypeSlug] : [rehypeSlug, highlight];
476
+
251
477
  return {
252
478
  enforce: "pre",
253
479
  ...mdx({
254
480
  jsxImportSource: "react",
255
- remarkPlugins: [remarkGfm, remarkFrontmatter, [remarkMdxFrontmatter, { name: "frontmatter" }]],
256
- rehypePlugins: [rehypeSlug],
481
+ remarkPlugins: [
482
+ remarkGfm,
483
+ remarkFrontmatter,
484
+ [remarkMdxFrontmatter, { name: "frontmatter" }],
485
+ ],
486
+ rehypePlugins,
257
487
  }),
258
488
  name: "uf:mdx",
259
489
  };
@@ -289,6 +519,134 @@ function wantsDocument(request) {
289
519
  return !/\.[a-z0-9]+$/i.test(pathname);
290
520
  }
291
521
 
522
+ /** The name of the environment variable that turns every finding back on. */
523
+ const ALL_DIAGNOSTICS = "UF_REACT_COMPILER_DIAGNOSTICS";
524
+
525
+ /**
526
+ * Report what the React Compiler said about one module.
527
+ *
528
+ * Every finding used to be printed as `a function: <message>` — no file, no
529
+ * line, no column, and the fallback string doing all the work because the
530
+ * compiler names an inner function about as often as not. The transform hook
531
+ * knows the module and the compiler gives a position for most findings, so
532
+ * both go into the message: Vite prints a plugin warning's `message` and
533
+ * nothing else, so a location that is not in the string is a location the
534
+ * reader never sees. `id` and `loc` go along for anything reading the log
535
+ * object rather than the line. See ubugeeei-prod/uf#307.
536
+ *
537
+ * A dependency's findings are held back. A React Compiler bailout inside
538
+ * `@uniflowed/form` is not something the person running the build can fix, and
539
+ * a channel carrying forty of them on every build is a channel people stop
540
+ * reading — which costs them the one finding that was theirs. They are counted
541
+ * and said once instead, and `UF_REACT_COMPILER_DIAGNOSTICS=all` prints every
542
+ * one for whoever is fixing the dependency.
543
+ */
544
+ function reportDiagnostics(context, { id, root, diagnostics, environment, reported, suppressed }) {
545
+ if (diagnostics.length === 0) return;
546
+ // A module transformed again by the environment that first reported it has
547
+ // been edited; anything else is the second bundle passing over the same file.
548
+ const previous = reported.get(id);
549
+ const ledger =
550
+ previous != null && previous.environment !== environment
551
+ ? previous
552
+ : { environment, signatures: new Set() };
553
+ reported.set(id, ledger);
554
+
555
+ const file = relativeId(root, id);
556
+ const mine = isProjectModule(root, id) || process.env[ALL_DIAGNOSTICS] === "all";
557
+ for (const diagnostic of diagnostics) {
558
+ // Everything a reader would be shown, so two findings that would print as
559
+ // the same line collapse into one. The compiler reports "Cannot access refs
560
+ // during render" once per pass that noticed it — three times for one `ref`
561
+ // — and three identical lines are not three things to fix.
562
+ const signature = `${diagnostic.kind}\0${diagnostic.line}\0${diagnostic.column}\0${diagnostic.message}`;
563
+ if (ledger.signatures.has(signature)) continue;
564
+ ledger.signatures.add(signature);
565
+ if (!mine) {
566
+ suppressed.push(file);
567
+ continue;
568
+ }
569
+ // Two conventions, both honoured. uf's own frames count columns from one
570
+ // (`uf_term::diagnostic`) and so does every editor a reader will paste
571
+ // `file:line:column` into; Rollup's `loc.column` counts from zero, which is
572
+ // what the compiler already gave us. The string gets the reader's number
573
+ // and the log object gets Rollup's.
574
+ const at = diagnostic.line == null ? "" : `:${diagnostic.line}:${(diagnostic.column ?? 0) + 1}`;
575
+ const who = diagnostic.function == null ? "" : ` (in ${diagnostic.function})`;
576
+ context.warn?.({
577
+ message: `${file}${at}: ${diagnostic.message}${who}`,
578
+ id,
579
+ loc:
580
+ diagnostic.line == null
581
+ ? undefined
582
+ : { file: id, line: diagnostic.line, column: diagnostic.column ?? 0 },
583
+ });
584
+ }
585
+ }
586
+
587
+ /**
588
+ * Say how many findings were a dependency's, and whose.
589
+ *
590
+ * Held back is not the same as hidden: a build that quietly drops forty
591
+ * findings is a build that has decided for the reader that uf has no bugs. One
592
+ * line names the packages and how to see the rest.
593
+ */
594
+ function summariseSuppressed(context, suppressed) {
595
+ if (suppressed.length === 0) return;
596
+ const packages = [...new Set(suppressed.map(packageOf))].sort();
597
+ const count = suppressed.length;
598
+ context.warn?.(
599
+ `${count} React Compiler ${count === 1 ? "finding" : "findings"} in ` +
600
+ `${packages.join(", ")} — not this application's to fix; ` +
601
+ `set ${ALL_DIAGNOSTICS}=all to see them`,
602
+ );
603
+ }
604
+
605
+ /**
606
+ * Whether a finding about this module is the application author's to act on.
607
+ *
608
+ * Inside the project root *and* outside `node_modules`, rather than
609
+ * `node_modules` alone: uf's own packages reach an application through a
610
+ * workspace link in this repository and through `node_modules` everywhere
611
+ * else, and they are no more the reader's code in one case than the other.
612
+ */
613
+ function isProjectModule(root, id) {
614
+ const relative = path.relative(root, id);
615
+ if (relative.startsWith("..") || path.isAbsolute(relative)) return false;
616
+ return !relative.split(path.sep).includes("node_modules");
617
+ }
618
+
619
+ /** A module's path as a reader would write it: relative, with forward slashes. */
620
+ function relativeId(root, id) {
621
+ const relative = path.relative(root, id);
622
+ return relative === "" ? id : relative.split(path.sep).join("/");
623
+ }
624
+
625
+ /** The package a module belongs to, for the one line that names them. */
626
+ function packageOf(file) {
627
+ const parts = file.split("/");
628
+ const at = parts.lastIndexOf("node_modules");
629
+ if (at !== -1) {
630
+ const scoped = parts[at + 1]?.startsWith("@");
631
+ return parts.slice(at + 1, at + (scoped ? 3 : 2)).join("/");
632
+ }
633
+ // No `node_modules` in the path: a workspace link, resolved to a checkout.
634
+ // The directory the module hangs off is the closest thing to a package name
635
+ // that is true without reading its `package.json` from a warning path.
636
+ const up = parts.lastIndexOf("packages");
637
+ return up === -1 ? parts.slice(0, -1).join("/") || file : parts.slice(up, up + 2).join("/");
638
+ }
639
+
640
+ /**
641
+ * Whether a hook is running for the server environment.
642
+ *
643
+ * Both spellings, for the reason `transform` above checks both: Vite 6 moved
644
+ * the answer onto the plugin context and the `ssr` option is the older one.
645
+ */
646
+ function isSsr(context, options) {
647
+ return options?.ssr === true || context?.environment?.name === "ssr";
648
+ }
649
+
292
650
  function cleanId(id) {
293
651
  const at = id.indexOf("?");
294
652
  return at === -1 ? id : id.slice(0, at);