@uniflowed/vite 0.0.0-alpha.7 → 0.0.0-alpha.9

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
@@ -16,10 +16,21 @@
16
16
  // that hydrates it, and the server entry that renders it. In
17
17
  // development it also renders every HTML request on the
18
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.
19
23
  // * `uf:mdx` — `@mdx-js/rollup`, configured for React with GitHub-flavoured
20
24
  // markdown, front matter, heading ids and build-time syntax
21
25
  // highlighting, so `.mdx` works with
22
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`.
23
34
  //
24
35
  // `uniflowed(options)` returns the array; a project that wants to add a plugin
25
36
  // declares it in `uf.config.js` and the driver appends it after these.
@@ -30,7 +41,8 @@ import path from "node:path";
30
41
  import mdx from "@mdx-js/rollup";
31
42
  import rehypeSlug from "rehype-slug";
32
43
 
33
- import { reportRenderError } from "./internal/events.js";
44
+ import { assetPlugin } from "./internal/assets.js";
45
+ import { emit, reportRenderError } from "./internal/events.js";
34
46
  import { highlightPlugin } from "./internal/highlight.js";
35
47
  import remarkFrontmatter from "remark-frontmatter";
36
48
  import remarkGfm from "remark-gfm";
@@ -43,6 +55,7 @@ import {
43
55
  preambleCode,
44
56
  refreshRuntimeSource,
45
57
  } from "./internal/refresh.js";
58
+ import { RSC_MANIFEST_ENV, clientRouteFilter, readRscManifest } from "./internal/rsc.js";
46
59
  import {
47
60
  RESERVED,
48
61
  VIRTUAL,
@@ -52,6 +65,8 @@ import {
52
65
  serverModuleSource,
53
66
  } from "./internal/routes.js";
54
67
  import { TransformService, isFlowModule } from "@uniflowed/host/transform";
68
+ import { send, toRequest } from "./internal/http.js";
69
+ import { withRequest } from "./internal/serve.js";
55
70
 
56
71
  /** A resolved virtual id: Vite's convention is a leading NUL byte. */
57
72
  const resolved = (id) => `\0${id}`;
@@ -92,8 +107,17 @@ export default function uniflowed(options = {}) {
92
107
  const routerRoot = app.router?.root ?? "app";
93
108
  const appEntry = app.router?.entry ?? ufConfig.build?.entries?.[0] ?? "app.js";
94
109
  const markdown = app.builtins?.markdown ?? {};
95
-
96
- return [flowPlugin({ routerRoot, appEntry, command: options.command }), mdxPlugin(markdown)];
110
+ const builtins = app.builtins ?? {};
111
+
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
+ ];
97
121
  }
98
122
 
99
123
  function flowPlugin({ routerRoot, appEntry, command }) {
@@ -138,6 +162,32 @@ function flowPlugin({ routerRoot, appEntry, command }) {
138
162
  return service;
139
163
  };
140
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
+
141
191
  return {
142
192
  name: "uf:flow",
143
193
  enforce: "pre",
@@ -194,9 +244,16 @@ function flowPlugin({ routerRoot, appEntry, command }) {
194
244
  return null;
195
245
  },
196
246
 
197
- load(id) {
247
+ load(id, loadOptions) {
198
248
  if (id === RUNTIME_RESOLVED_ID) return refreshRuntimeSource();
199
- 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
+ }
200
257
  if (id === resolved(VIRTUAL.client)) return clientModuleSource(entryPath);
201
258
  if (id === resolved(VIRTUAL.server)) return serverModuleSource(entryPath);
202
259
  if (id.startsWith(STYLE_PREFIX)) return styles.get(id) ?? "";
@@ -308,6 +365,27 @@ function flowPlugin({ routerRoot, appEntry, command }) {
308
365
  devServer.watcher.on("add", onRouteFile);
309
366
  devServer.watcher.on("unlink", onRouteFile);
310
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
+
311
389
  // After Vite's own middlewares, so `/@vite/client`, `/@id/...` and
312
390
  // static files are served first and only a document request reaches
313
391
  // the renderer.
@@ -316,20 +394,66 @@ function flowPlugin({ routerRoot, appEntry, command }) {
316
394
  if (!wantsDocument(request)) return next();
317
395
  try {
318
396
  const url = request.url ?? "/";
319
- const { render } = await importServerEntry(devServer);
320
- const result = await render(url, {
321
- scripts: [devUrlFor(VIRTUAL.client)],
322
- styles: [],
323
- 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);
324
456
  });
325
- if (result.error != null) reportRenderError(devServer, url, result.error);
326
- const html = await devServer.transformIndexHtml(url, result.html);
327
- response.statusCode = result.status;
328
- response.setHeader("Content-Type", "text/html; charset=utf-8");
329
- for (const [name, value] of Object.entries(result.headers ?? {})) {
330
- response.setHeader(name, value);
331
- }
332
- response.end(html);
333
457
  } catch (error) {
334
458
  devServer.ssrFixStacktrace(error);
335
459
  next(error);
@@ -513,6 +637,16 @@ function packageOf(file) {
513
637
  return up === -1 ? parts.slice(0, -1).join("/") || file : parts.slice(up, up + 2).join("/");
514
638
  }
515
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
+
516
650
  function cleanId(id) {
517
651
  const at = id.indexOf("?");
518
652
  return at === -1 ? id : id.slice(0, at);
@@ -0,0 +1,396 @@
1
+ // @noflow
2
+ //
3
+ // Plain JavaScript: Vite imports this module directly, before any transform.
4
+ //
5
+ // `uf:asset` — what an imported image or font becomes.
6
+ //
7
+ // The name and the hook set are not new. `crates/uf_plugin/src/builtin.rs` has
8
+ // declared `uf:asset` — "resolves, fingerprints, and emits non-JavaScript
9
+ // imports", `resolveId` + `load` + `generateBundle` + `writeBundle` +
10
+ // `transformIndexHtml` — since before there was anything behind it, and
11
+ // `uf inspect` has been listing it in the resolved pipeline. This is the
12
+ // implementation of a plugin uf was already claiming to run.
13
+ //
14
+ // # What an import becomes
15
+ //
16
+ // ```js
17
+ // import hero from "./hero.jpg";
18
+ // <Image src={hero} alt="…" sizes="(max-width: 640px) 100vw, 640px" />
19
+ // ```
20
+ //
21
+ // `hero` is not a URL string. It is the manifest `crates/uf_assets` produced —
22
+ // the intrinsic width and height, every emitted variant with its own width, the
23
+ // blur placeholder — because a `srcSet` can only be written by something that
24
+ // knows which other sizes exist, and a URL string does not.
25
+ //
26
+ // A font import is the same shape: the self-hosted file, the `@font-face` rules
27
+ // that declare it, and the metric-matched fallback.
28
+ //
29
+ // # Where the work happens, and when
30
+ //
31
+ // In `uf`, over the `uf assets` protocol — one native process for the whole
32
+ // build rather than an image codec in the dependency tree. Both schedules go
33
+ // through the same process with the same parameters, and both write to the
34
+ // same cache directory:
35
+ //
36
+ // * **`uf build`** reads each emitted variant out of the cache and hands it to
37
+ // Rollup with `emitFile`, so the bundler owns what lands in `dist/` and the
38
+ // size report counts it.
39
+ // * **`uf dev`** serves the same files out of the same cache directory over a
40
+ // middleware, transformed on the first import and reused after.
41
+ //
42
+ // The files are named by a content hash of the source and the parameters, so
43
+ // the second build of an unchanged image does no work in either mode and a dev
44
+ // session warms the cache a build then reuses. That is also the whole of "the
45
+ // two must agree": there is one pipeline and one set of bytes, and the only
46
+ // thing that differs between them is the URL prefix they are served under.
47
+ //
48
+ // # What this plugin deliberately does not claim
49
+ //
50
+ // An import with a query — `./hero.png?url`, `?raw`, `?inline` — is left to
51
+ // Vite. Those are Vite's own asset conventions and a project reaching for one
52
+ // is reaching past uf on purpose; claiming them here would make a documented
53
+ // Vite feature unreachable from a uf project, which is red line 8 in
54
+ // `docs/red-lines.md`. `import hero from "./hero.png"` is uf's; everything
55
+ // with a `?` after it is Vite's.
56
+
57
+ import { existsSync, readFileSync } from "node:fs";
58
+ import path from "node:path";
59
+
60
+ import { AssetService, assetKind } from "@uniflowed/host/assets";
61
+
62
+ /** Where transformed assets are kept, relative to the project root. */
63
+ export const CACHE_DIR = ".uf/cache/assets";
64
+
65
+ /**
66
+ * The URL prefix a dev server answers transformed assets on.
67
+ *
68
+ * `@` first, following the convention Vite uses for everything that is not a
69
+ * file in the project: it cannot collide with a real path, and Vite's own
70
+ * middlewares leave it alone.
71
+ */
72
+ export const DEV_PREFIX = "@uf-asset/";
73
+
74
+ /**
75
+ * The module source for one transformed asset.
76
+ *
77
+ * A frozen object literal rather than a JSON blob assigned to a variable: this
78
+ * is what the component destructures, it is small, and a build that inlines it
79
+ * into the one component that used it is the right outcome.
80
+ */
81
+ export function assetModuleSource(manifest) {
82
+ return `export default Object.freeze(${JSON.stringify(manifest)});\n`;
83
+ }
84
+
85
+ /**
86
+ * The `srcSet` for one format, and the URLs that go in it.
87
+ *
88
+ * Written here rather than in the component so that a build and a dev server
89
+ * cannot produce different strings from the same manifest: the only input that
90
+ * differs between them is `baseUrl`, and it is an argument.
91
+ */
92
+ export function withUrls(image, baseUrl) {
93
+ const variants = image.variants.map((variant) => ({
94
+ ...variant,
95
+ url: `${baseUrl}${variant.file}`,
96
+ }));
97
+ // Widest last within a format, which is the order a `srcset` reads best in
98
+ // and the order `sizes` is evaluated against.
99
+ variants.sort((left, right) => left.width - right.width);
100
+
101
+ const formats = [];
102
+ for (const variant of variants) {
103
+ if (variant.format === image.format) continue;
104
+ if (!formats.includes(variant.format)) formats.push(variant.format);
105
+ }
106
+
107
+ const srcSetFor = (format) =>
108
+ variants
109
+ .filter((variant) => variant.format === format)
110
+ // `640w` and not `2x`: a density descriptor describes one layout width,
111
+ // and the whole point of the ladder is that the layout width is not
112
+ // known here. With `w`, the browser combines it with `sizes` and picks.
113
+ .map((variant) => `${variant.url} ${variant.width}w`)
114
+ .join(", ");
115
+
116
+ const fallbacks = variants.filter((variant) => variant.format === image.format);
117
+ const widest = fallbacks[fallbacks.length - 1] ?? variants[variants.length - 1];
118
+
119
+ return {
120
+ src: widest?.url ?? null,
121
+ width: image.width,
122
+ height: image.height,
123
+ srcSet: srcSetFor(image.format),
124
+ // Alternatives first: a browser takes the first `<source>` it understands,
125
+ // so the format every browser understands must not be offered before the
126
+ // ones that are smaller.
127
+ sources: formats.map((format) => ({
128
+ type: variants.find((variant) => variant.format === format).mime,
129
+ srcSet: srcSetFor(format),
130
+ })),
131
+ blurDataURL: image.blur,
132
+ // Carried through so a project can see what the pipeline decided and why,
133
+ // rather than having to infer it from what is missing: `hero.declined` is
134
+ // the widths where the alternative format was encoded and came out larger,
135
+ // with both byte counts. Nothing prints them — a line on every build about
136
+ // a format that was correctly not emitted is noise — and `uf explain build`
137
+ // is where the limit itself is stated.
138
+ declined: image.declined,
139
+ note: image.note,
140
+ };
141
+ }
142
+
143
+ /**
144
+ * uf's asset pipeline, as a Vite plugin.
145
+ *
146
+ * @param {object} options
147
+ * @param {object} [options.images] `app.builtins.images`
148
+ * @param {object} [options.fonts] `app.builtins.fonts`
149
+ * @param {string} [options.command] the `uf` binary to transform through
150
+ */
151
+ export function assetPlugin({ images = {}, fonts = {}, command } = {}) {
152
+ // Both halves can be turned off independently, and a plugin that is off is
153
+ // still in the array: `uf inspect` lists the resolved pipeline, and a
154
+ // pipeline that changes shape when a feature is disabled is a pipeline whose
155
+ // listing cannot be compared between two projects.
156
+ const imagesOn = images.enabled !== false;
157
+ const fontsOn = fonts.enabled !== false;
158
+
159
+ let root = process.cwd();
160
+ let base = "/";
161
+ let assetsDir = "assets";
162
+ let isBuild = false;
163
+ /** @type {import("vite").ViteDevServer | null} */
164
+ let server = null;
165
+ /** @type {AssetService | null} */
166
+ let service = null;
167
+ /**
168
+ * The manifest for each source path, so one image is transformed once.
169
+ *
170
+ * `uf build` runs Vite twice over the same modules — once for the browser
171
+ * bundle and once for the server one — from a single plugin array, so both
172
+ * passes share this map and the second decodes nothing. It survives
173
+ * `buildEnd` deliberately: clearing it there is what made the server pass
174
+ * redo every image, which is the whole cost this map exists to avoid.
175
+ */
176
+ const transformed = new Map();
177
+
178
+ const cacheDir = () => path.resolve(root, CACHE_DIR);
179
+ /**
180
+ * The `uf assets` process, started on the first asset and not before.
181
+ *
182
+ * Lazily rather than in `buildStart`, which is where `uf:flow` starts its
183
+ * transform service: that one is going to be asked about every module in the
184
+ * project, and this one is asked about nothing at all in a project that
185
+ * imports no images or fonts. A build that has no use for an image codec
186
+ * should not spawn one.
187
+ */
188
+ const ensureService = () => {
189
+ service ??= new AssetService({ command, root });
190
+ return service;
191
+ };
192
+
193
+ /**
194
+ * Where a transformed file is served from.
195
+ *
196
+ * A build's URL is the bundler's output directory; a dev server's is this
197
+ * plugin's own middleware. This is the *only* thing that differs between the
198
+ * two schedules, and it is one string.
199
+ */
200
+ const baseUrl = () => (isBuild ? `${base}${assetsDir}/` : `${base}${DEV_PREFIX}`);
201
+
202
+ const claims = (id) => {
203
+ // A query is Vite's, not uf's. See the header.
204
+ if (id.includes("?")) return null;
205
+ if (id.startsWith("\0")) return null;
206
+ const kind = assetKind(id);
207
+ if (kind === "image" && !imagesOn) return null;
208
+ if (kind === "font" && !fontsOn) return null;
209
+ return kind;
210
+ };
211
+
212
+ return {
213
+ name: "uf:asset",
214
+ // Before Vite's own asset handling, which would otherwise claim the same
215
+ // extensions and return a URL string.
216
+ enforce: "pre",
217
+
218
+ configResolved(config) {
219
+ root = config.root;
220
+ base = config.base;
221
+ assetsDir = config.build?.assetsDir ?? "assets";
222
+ isBuild = config.command === "build";
223
+ },
224
+
225
+ async load(id) {
226
+ const kind = claims(id);
227
+ if (kind == null) return null;
228
+ const file = path.resolve(id);
229
+ // Not this plugin's to fail on: an id with one of these extensions that
230
+ // is not a file on disk is a virtual module somebody else owns.
231
+ if (!existsSync(file)) return null;
232
+
233
+ return loadAsset.call(this, {
234
+ kind,
235
+ file,
236
+ transformed,
237
+ service: ensureService(),
238
+ cacheDir: cacheDir(),
239
+ baseUrl: baseUrl(),
240
+ assetsDir,
241
+ isBuild,
242
+ images,
243
+ fonts,
244
+ });
245
+ },
246
+
247
+ configureServer(devServer) {
248
+ server = devServer;
249
+ devServer.httpServer?.once("close", () => {
250
+ service?.close();
251
+ service = null;
252
+ });
253
+
254
+ // Before Vite's own middlewares: nothing else knows this prefix, and the
255
+ // files are outside the module graph, so there is nothing to wait for.
256
+ const directory = cacheDir();
257
+ devServer.middlewares.use((request, response, next) => {
258
+ const url = request.url ?? "";
259
+ const at = url.indexOf(DEV_PREFIX);
260
+ if (at === -1) return next();
261
+ const name = decodeURIComponent(url.slice(at + DEV_PREFIX.length).split("?")[0]);
262
+ // The name is a file name and nothing else. Every emitted name is one
263
+ // path segment by construction, so a request carrying a separator is
264
+ // not a name this plugin ever minted — refusing it rather than
265
+ // resolving it is what keeps the cache directory from being a way to
266
+ // read the rest of the disk.
267
+ if (name === "" || name.includes("/") || name.includes("\\") || name.includes("..")) {
268
+ response.statusCode = 400;
269
+ response.end("bad asset name");
270
+ return;
271
+ }
272
+ const target = path.join(directory, name);
273
+ if (!existsSync(target)) return next();
274
+ response.setHeader("Content-Type", contentTypeOf(name));
275
+ // The name is a content hash, so the bytes under it never change.
276
+ response.setHeader("Cache-Control", "public, max-age=31536000, immutable");
277
+ response.end(readFileSync(target));
278
+ });
279
+ },
280
+
281
+ watchChange(id) {
282
+ // The memo below is what stops `uf build` decoding every image twice,
283
+ // once per bundle. In a dev server it would also stop uf ever noticing
284
+ // that an image was edited: Vite invalidates the module and calls `load`
285
+ // again, and `load` would hand back the manifest it made before the
286
+ // change. Dropping both keys is cheap and the next `load` redoes the
287
+ // work — which, because the emitted names are content hashes, writes new
288
+ // files and leaves the old ones for anything still holding a URL.
289
+ transformed.delete(`image:${path.resolve(id)}`);
290
+ transformed.delete(`font:${path.resolve(id)}`);
291
+ },
292
+
293
+ buildEnd() {
294
+ // A dev server keeps its service for the whole session; a build is done
295
+ // with it here. The same rule `uf:flow` follows next door.
296
+ //
297
+ // `transformed` is *not* cleared. A build's second pass over the same
298
+ // modules then needs no process at all — every answer is already in the
299
+ // map, and `ensureService` is never reached.
300
+ if (server == null) {
301
+ service?.close();
302
+ service = null;
303
+ }
304
+ },
305
+ };
306
+ }
307
+
308
+ /**
309
+ * Transform one asset and return the module that stands for it.
310
+ *
311
+ * Split out of the hook so the hook stays readable and so the memoisation is
312
+ * visible: `uf build` runs Vite twice over the same modules, once for the
313
+ * browser bundle and once for the server one, and an image transformed on both
314
+ * passes would be decoded twice for one build.
315
+ */
316
+ async function loadAsset(context) {
317
+ const { kind, file, transformed, service, cacheDir, baseUrl, assetsDir, isBuild, images, fonts } =
318
+ context;
319
+ const key = `${kind}:${file}`;
320
+ let manifest = transformed.get(key);
321
+ if (manifest == null) {
322
+ manifest =
323
+ kind === "image"
324
+ ? await service.image(file, {
325
+ outDir: cacheDir,
326
+ widths: images.widths,
327
+ quality: images.quality,
328
+ blur: images.placeholder,
329
+ })
330
+ : await service.font(file, {
331
+ outDir: cacheDir,
332
+ family: fonts.family,
333
+ display: fonts.display,
334
+ baseUrl,
335
+ });
336
+ transformed.set(key, manifest);
337
+ }
338
+
339
+ const files = kind === "image" ? manifest.variants.map((v) => v.file) : [manifest.file];
340
+ if (isBuild) {
341
+ // Handed to Rollup rather than copied by hand, so the bundler owns what
342
+ // lands in the output directory and `uf_bundle`'s size report — which
343
+ // walks that directory — counts every one of them.
344
+ for (const name of files) {
345
+ this.emitFile({
346
+ type: "asset",
347
+ // `fileName` rather than `name`: the name is already a content hash of
348
+ // the source and the parameters, and letting Rollup hash it again
349
+ // would move it on every encoder change while saying nothing new.
350
+ fileName: `${assetsDir}/${name}`,
351
+ source: readFileSync(path.join(cacheDir, name)),
352
+ });
353
+ }
354
+ }
355
+
356
+ if (kind === "image") {
357
+ return assetModuleSource(withUrls(manifest, baseUrl));
358
+ }
359
+ return assetModuleSource({
360
+ src: `${baseUrl}${manifest.file}`,
361
+ family: manifest.family,
362
+ fallbackFamily: manifest.fallbackFamily,
363
+ // The stack a page should set `font-family` to: the real face, then the
364
+ // metric-matched fallback, then the local face it was scaled from. Written
365
+ // here so no page has to remember that the fallback only ever applies when
366
+ // it is named after the real face.
367
+ fontFamily: [manifest.family, manifest.fallbackFamily, manifest.fallback?.local]
368
+ .filter((name) => name != null)
369
+ .map((name) => JSON.stringify(name))
370
+ .join(", "),
371
+ type: manifest.mime,
372
+ css: manifest.css,
373
+ metrics: manifest.metrics,
374
+ fallback: manifest.fallback,
375
+ fallbackDeclined: manifest.fallbackDeclined,
376
+ });
377
+ }
378
+
379
+ /** The media type for one emitted file name. */
380
+ function contentTypeOf(name) {
381
+ const extension = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
382
+ const types = {
383
+ avif: "image/avif",
384
+ gif: "image/gif",
385
+ jpg: "image/jpeg",
386
+ jpeg: "image/jpeg",
387
+ otf: "font/otf",
388
+ png: "image/png",
389
+ svg: "image/svg+xml",
390
+ ttf: "font/ttf",
391
+ webp: "image/webp",
392
+ woff: "font/woff",
393
+ woff2: "font/woff2",
394
+ };
395
+ return types[extension] ?? "application/octet-stream";
396
+ }