@uniflowed/vite 0.0.0-alpha.4 → 0.0.0-alpha.40

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.
@@ -0,0 +1,644 @@
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 — and `resolveId`
13
+ // and `generateBundle`, declared there from the start and unimplemented until
14
+ // icons arrived, are now real: the first resolves `uf:icon/…`, the second
15
+ // assembles the sprite once the graph is complete.
16
+ //
17
+ // # What an import becomes
18
+ //
19
+ // ```js
20
+ // import hero from "./hero.jpg";
21
+ // <Image src={hero} alt="…" sizes="(max-width: 640px) 100vw, 640px" />
22
+ // ```
23
+ //
24
+ // `hero` is not a URL string. It is the manifest `crates/uf_assets` produced —
25
+ // the intrinsic width and height, every emitted variant with its own width, the
26
+ // blur placeholder — because a `srcSet` can only be written by something that
27
+ // knows which other sizes exist, and a URL string does not.
28
+ //
29
+ // A font import is the same shape: the self-hosted file, the `@font-face` rules
30
+ // that declare it, and the metric-matched fallback.
31
+ //
32
+ // # Where the work happens, and when
33
+ //
34
+ // In `uf`, over the `uf assets` protocol — one native process for the whole
35
+ // build rather than an image codec in the dependency tree. Both schedules go
36
+ // through the same process with the same parameters, and both write to the
37
+ // same cache directory:
38
+ //
39
+ // * **`uf build`** reads each emitted variant out of the cache and hands it to
40
+ // Rollup with `emitFile`, so the bundler owns what lands in `dist/` and the
41
+ // size report counts it.
42
+ // * **`uf dev`** serves the same files out of the same cache directory over a
43
+ // middleware, transformed on the first import and reused after.
44
+ //
45
+ // The files are named by a content hash of the source and the parameters, so
46
+ // the second build of an unchanged image does no work in either mode and a dev
47
+ // session warms the cache a build then reuses. That is also the whole of "the
48
+ // two must agree": there is one pipeline and one set of bytes, and the only
49
+ // thing that differs between them is the URL prefix they are served under.
50
+ //
51
+ // # Icons, and why they are not `.svg`
52
+ //
53
+ // `import Star from "uf:icon/star"` resolves against the directory
54
+ // `app.builtins.icons.dir` names, and `import sprite from "uf:icon-sprite"` is
55
+ // the sprite built from every icon the build reached. Neither claims an
56
+ // extension, which is the point: `.svg` stays Vite's, so `vite-plugin-svgr`
57
+ // and everything like it keep working, and uf adds a capability in its own
58
+ // namespace instead of taking one away.
59
+ //
60
+ // The sprite is emitted in `generateBundle`, which is the first hook that runs
61
+ // after every module has been loaded — and therefore the first moment the set
62
+ // of icons a build reached is the whole set.
63
+ //
64
+ // # What this plugin deliberately does not claim
65
+ //
66
+ // An import with a query — `./hero.png?url`, `?raw`, `?inline` — is left to
67
+ // Vite. Those are Vite's own asset conventions and a project reaching for one
68
+ // is reaching past uf on purpose; claiming them here would make a documented
69
+ // Vite feature unreachable from a uf project, which is red line 8 in
70
+ // `docs/red-lines.md`. `import hero from "./hero.png"` is uf's; everything
71
+ // with a `?` after it is Vite's.
72
+
73
+ import { existsSync, readFileSync } from "node:fs";
74
+ import path from "node:path";
75
+
76
+ import {
77
+ AssetService,
78
+ ICON_PREFIX,
79
+ ICON_SPRITE,
80
+ OG_EXTENSION,
81
+ assetKind,
82
+ } from "@uniflowed/host/assets";
83
+
84
+ /** Where transformed assets are kept, relative to the project root. */
85
+ export const CACHE_DIR = ".uf/cache/assets";
86
+
87
+ /**
88
+ * The URL prefix a dev server answers transformed assets on.
89
+ *
90
+ * `@` first, following the convention Vite uses for everything that is not a
91
+ * file in the project: it cannot collide with a real path, and Vite's own
92
+ * middlewares leave it alone.
93
+ */
94
+ export const DEV_PREFIX = "@uf-asset/";
95
+
96
+ /**
97
+ * What stands in for the sprite until `generateBundle` knows what is in it.
98
+ *
99
+ * A string no source file would contain, replaced in the generated chunk once
100
+ * every module has been loaded. The module graph needs *something* at `load`
101
+ * time and the sprite is not knowable then; see the `sprite` branch of `load`.
102
+ */
103
+ const SPRITE_PLACEHOLDER = "__UF_ICON_SPRITE__";
104
+
105
+ /**
106
+ * What a `*.og.json` import resolves to.
107
+ *
108
+ * A card cannot keep its own id, and the reason is worth writing down because
109
+ * nothing about `enforce: "pre"` prevents it. Vite's JSON handling is a
110
+ * *native* rolldown plugin, `builtin:vite-json`, and it selects modules by
111
+ * **id**: anything still ending in `.json` when its `transform` runs is put
112
+ * through a JSON parser, whatever an earlier `load` returned. Ordering the
113
+ * hooks does not help, because the module this plugin loads is JavaScript
114
+ * under a name that says JSON — and the parser's answer is
115
+ * `expected value at line 1 column 1`.
116
+ *
117
+ * So the id changes rather than the order. The card resolves to
118
+ * `\0uf-og:<absolute path>.js`: `\0` is the convention for a module that is
119
+ * not a file, and the trailing extension is what takes it out of every
120
+ * `.json` filter in the pipeline.
121
+ */
122
+ const OG_PREFIX = "\0uf-og:";
123
+ /** Appended to that id so it does not end in `.json`. See [`OG_PREFIX`]. */
124
+ const OG_SUFFIX = ".js";
125
+
126
+ /**
127
+ * The module source for one transformed asset.
128
+ *
129
+ * A frozen object literal rather than a JSON blob assigned to a variable: this
130
+ * is what the component destructures, it is small, and a build that inlines it
131
+ * into the one component that used it is the right outcome.
132
+ */
133
+ export function assetModuleSource(manifest) {
134
+ return `export default Object.freeze(${JSON.stringify(manifest)});\n`;
135
+ }
136
+
137
+ /**
138
+ * The `srcSet` for one format, and the URLs that go in it.
139
+ *
140
+ * Written here rather than in the component so that a build and a dev server
141
+ * cannot produce different strings from the same manifest: the only input that
142
+ * differs between them is `baseUrl`, and it is an argument.
143
+ */
144
+ export function withUrls(image, baseUrl) {
145
+ const variants = image.variants.map((variant) => ({
146
+ ...variant,
147
+ url: `${baseUrl}${variant.file}`,
148
+ }));
149
+ // Widest last within a format, which is the order a `srcset` reads best in
150
+ // and the order `sizes` is evaluated against.
151
+ variants.sort((left, right) => left.width - right.width);
152
+
153
+ const formats = [];
154
+ for (const variant of variants) {
155
+ if (variant.format === image.format) continue;
156
+ if (!formats.includes(variant.format)) formats.push(variant.format);
157
+ }
158
+
159
+ const srcSetFor = (format) =>
160
+ variants
161
+ .filter((variant) => variant.format === format)
162
+ // `640w` and not `2x`: a density descriptor describes one layout width,
163
+ // and the whole point of the ladder is that the layout width is not
164
+ // known here. With `w`, the browser combines it with `sizes` and picks.
165
+ .map((variant) => `${variant.url} ${variant.width}w`)
166
+ .join(", ");
167
+
168
+ const fallbacks = variants.filter((variant) => variant.format === image.format);
169
+ const widest = fallbacks[fallbacks.length - 1] ?? variants[variants.length - 1];
170
+
171
+ return {
172
+ src: widest?.url ?? null,
173
+ width: image.width,
174
+ height: image.height,
175
+ srcSet: srcSetFor(image.format),
176
+ // Alternatives first: a browser takes the first `<source>` it understands,
177
+ // so the format every browser understands must not be offered before the
178
+ // ones that are smaller.
179
+ sources: formats.map((format) => ({
180
+ type: variants.find((variant) => variant.format === format).mime,
181
+ srcSet: srcSetFor(format),
182
+ })),
183
+ blurDataURL: image.blur,
184
+ // Carried through so a project can see what the pipeline decided and why,
185
+ // rather than having to infer it from what is missing: `hero.declined` is
186
+ // the widths where the alternative format was encoded and came out larger,
187
+ // with both byte counts. Nothing prints them — a line on every build about
188
+ // a format that was correctly not emitted is noise — and `uf explain build`
189
+ // is where the limit itself is stated.
190
+ declined: image.declined,
191
+ note: image.note,
192
+ };
193
+ }
194
+
195
+ /**
196
+ * uf's asset pipeline, as a Vite plugin.
197
+ *
198
+ * @param {object} options
199
+ * @param {object} [options.images] `app.builtins.images`
200
+ * @param {object} [options.fonts] `app.builtins.fonts`
201
+ * @param {object} [options.icons] `app.builtins.icons`
202
+ * @param {object} [options.og] `app.builtins.og`
203
+ * @param {string} [options.command] the `uf` binary to transform through
204
+ */
205
+ export function assetPlugin({ images = {}, fonts = {}, icons = {}, og = {}, command } = {}) {
206
+ // Both halves can be turned off independently, and a plugin that is off is
207
+ // still in the array: `uf inspect` lists the resolved pipeline, and a
208
+ // pipeline that changes shape when a feature is disabled is a pipeline whose
209
+ // listing cannot be compared between two projects.
210
+ const imagesOn = images.enabled !== false;
211
+ const fontsOn = fonts.enabled !== false;
212
+ const iconsOn = icons.enabled !== false;
213
+ const ogOn = og.enabled !== false;
214
+ const iconDir = icons.dir ?? "icons";
215
+
216
+ let root = process.cwd();
217
+ let base = "/";
218
+ let assetsDir = "assets";
219
+ let isBuild = false;
220
+ /** @type {import("vite").ViteDevServer | null} */
221
+ let server = null;
222
+ /** @type {AssetService | null} */
223
+ let service = null;
224
+ /**
225
+ * The manifest for each source path, so one image is transformed once.
226
+ *
227
+ * `uf build` runs Vite twice over the same modules — once for the browser
228
+ * bundle and once for the server one — from a single plugin array, so both
229
+ * passes share this map and the second decodes nothing. It survives
230
+ * `buildEnd` deliberately: clearing it there is what made the server pass
231
+ * redo every image, which is the whole cost this map exists to avoid.
232
+ */
233
+ const transformed = new Map();
234
+ /** Whether anything imported the sprite at all. */
235
+ let spriteRequested = false;
236
+ /**
237
+ * Every icon this build has reached, by symbol id.
238
+ *
239
+ * Here and not in the `uf assets` process, because this closure outlives it:
240
+ * a build closes the service in `buildEnd`, which runs *before*
241
+ * `generateBundle`, and a build with a client environment and a server one
242
+ * has two bundles and one set of icons between them. The plugin is what
243
+ * spans a build, so the plugin is what remembers.
244
+ */
245
+ const reachedIcons = new Map();
246
+
247
+ const cacheDir = () => path.resolve(root, CACHE_DIR);
248
+ /**
249
+ * The `uf assets` process, started on the first asset and not before.
250
+ *
251
+ * Lazily rather than in `buildStart`, which is where `uf:flow` starts its
252
+ * transform service: that one is going to be asked about every module in the
253
+ * project, and this one is asked about nothing at all in a project that
254
+ * imports no images or fonts. A build that has no use for an image codec
255
+ * should not spawn one.
256
+ */
257
+ const ensureService = () => {
258
+ service ??= new AssetService({ command, root });
259
+ return service;
260
+ };
261
+
262
+ /**
263
+ * Where a transformed file is served from.
264
+ *
265
+ * A build's URL is the bundler's output directory; a dev server's is this
266
+ * plugin's own middleware. This is the *only* thing that differs between the
267
+ * two schedules, and it is one string.
268
+ */
269
+ const baseUrl = () => (isBuild ? `${base}${assetsDir}/` : `${base}${DEV_PREFIX}`);
270
+
271
+ const claims = (id) => {
272
+ // A query is Vite's, not uf's. See the header.
273
+ if (id.includes("?")) return null;
274
+ if (id.startsWith("\0")) return null;
275
+ const kind = assetKind(id);
276
+ if (kind === "image" && !imagesOn) return null;
277
+ if (kind === "font" && !fontsOn) return null;
278
+ if (kind === "og" && !ogOn) return null;
279
+ if ((kind === "icon" || kind === "sprite") && !iconsOn) return null;
280
+ return kind;
281
+ };
282
+
283
+ /**
284
+ * The file one `uf:icon/<name>` names.
285
+ *
286
+ * The name is a path segment and nothing else. `..` in it would reach out of
287
+ * the icon directory and turn an import into a way to read the repository,
288
+ * so it is rejected rather than resolved — the same rule the dev middleware
289
+ * applies to an emitted file name.
290
+ */
291
+ const iconFile = (id) => {
292
+ const name = id.slice(ICON_PREFIX.length);
293
+ if (name === "" || name.includes("..") || path.isAbsolute(name)) return null;
294
+ return { name, file: path.resolve(root, iconDir, `${name}.svg`) };
295
+ };
296
+
297
+ /** Tell a dev server the sprite it already sent is missing an icon. */
298
+ const invalidateSprite = () => {
299
+ if (server == null || !spriteRequested) return;
300
+ const module = server.moduleGraph.getModuleById(ICON_SPRITE);
301
+ if (module != null) server.moduleGraph.invalidateModule(module);
302
+ };
303
+
304
+ return {
305
+ name: "uf:asset",
306
+ // Before Vite's own asset handling, which would otherwise claim the same
307
+ // extensions and return a URL string.
308
+ enforce: "pre",
309
+
310
+ configResolved(config) {
311
+ root = config.root;
312
+ base = config.base;
313
+ assetsDir = config.build?.assetsDir ?? "assets";
314
+ isBuild = config.command === "build";
315
+ },
316
+
317
+ // `uf:icon/…` and `uf:icon-sprite` are uf's own ids and resolve to
318
+ // themselves. Returning the id unchanged rather than a `\0`-prefixed one
319
+ // keeps it readable in a stack trace and in `vite --debug`, and nothing
320
+ // else in the pipeline claims the `uf:` scheme.
321
+ //
322
+ // A card is the opposite case: it has to *lose* its name, because the name
323
+ // is what the native JSON plugin claims it by. See `OG_PREFIX`.
324
+ async resolveId(id, importer, options) {
325
+ if (iconsOn && (id === ICON_SPRITE || id.startsWith(ICON_PREFIX))) return id;
326
+ if (!ogOn || !id.toLowerCase().endsWith(OG_EXTENSION)) return null;
327
+ // Compared against the id as written, so a query is never claimed:
328
+ // `./card.og.json?raw` and `?url` still reach the file, because a query
329
+ // is Vite's.
330
+ const resolved = await this.resolve(id, importer, { ...options, skipSelf: true });
331
+ if (resolved == null || resolved.external || !existsSync(resolved.id)) return resolved;
332
+ return `${OG_PREFIX}${resolved.id}${OG_SUFFIX}`;
333
+ },
334
+
335
+ async load(id) {
336
+ if (id.startsWith(OG_PREFIX)) {
337
+ const file = id.slice(OG_PREFIX.length, -OG_SUFFIX.length);
338
+ // Watched by hand, because the module id is no longer the file's path
339
+ // and nothing else would associate the two. Without this a dev server
340
+ // never redraws a card whose template was edited.
341
+ this.addWatchFile(file);
342
+ return loadAsset.call(this, {
343
+ kind: "og",
344
+ file,
345
+ transformed,
346
+ service: ensureService(),
347
+ cacheDir: cacheDir(),
348
+ baseUrl: baseUrl(),
349
+ assetsDir,
350
+ isBuild,
351
+ images,
352
+ fonts,
353
+ });
354
+ }
355
+
356
+ const kind = claims(id);
357
+ if (kind == null) return null;
358
+
359
+ if (kind === "sprite") {
360
+ spriteRequested = true;
361
+ // A build cannot know the sprite here: `load` runs while the graph is
362
+ // still being walked, so the set of icons reached so far is not the
363
+ // set. It emits a placeholder that `generateBundle` — the first hook
364
+ // after every module has been loaded — replaces with the real markup.
365
+ //
366
+ // A dev server has no `generateBundle`, so it assembles from what has
367
+ // been reached and invalidates this module whenever a new icon turns
368
+ // up. That costs one extra reload the first time a page introduces an
369
+ // icon and is exactly right afterwards, which is the trade a dev
370
+ // server makes everywhere else too.
371
+ if (isBuild) return assetModuleSource({ markup: SPRITE_PLACEHOLDER });
372
+ const sprite = await ensureService().sprite({
373
+ outDir: cacheDir(),
374
+ icons: [...reachedIcons.values()],
375
+ });
376
+ return assetModuleSource({ markup: sprite.markup });
377
+ }
378
+
379
+ if (kind === "icon") {
380
+ const resolved = iconFile(id);
381
+ if (resolved == null) return null;
382
+ if (!existsSync(resolved.file)) {
383
+ this.error(
384
+ `${id} does not exist: uf looked for ${path.relative(root, resolved.file)}. ` +
385
+ "`app.builtins.icons.dir` is where `uf:icon/…` resolves against.",
386
+ );
387
+ }
388
+ const asset = await ensureService().icon(resolved.file, {
389
+ outDir: cacheDir(),
390
+ name: resolved.name,
391
+ });
392
+ this.addWatchFile(resolved.file);
393
+ reachedIcons.set(asset.id, asset);
394
+ invalidateSprite();
395
+ return assetModuleSource({
396
+ id: asset.id,
397
+ href: `#${asset.id}`,
398
+ viewBox: asset.viewBox,
399
+ width: asset.width,
400
+ height: asset.height,
401
+ });
402
+ }
403
+
404
+ const file = path.resolve(id);
405
+ // Not this plugin's to fail on: an id with one of these extensions that
406
+ // is not a file on disk is a virtual module somebody else owns.
407
+ if (!existsSync(file)) return null;
408
+
409
+ return loadAsset.call(this, {
410
+ kind,
411
+ file,
412
+ transformed,
413
+ service: ensureService(),
414
+ cacheDir: cacheDir(),
415
+ baseUrl: baseUrl(),
416
+ assetsDir,
417
+ isBuild,
418
+ images,
419
+ fonts,
420
+ });
421
+ },
422
+
423
+ // After every module has been loaded, which is the first moment the set of
424
+ // icons this build reached is the whole set. A sprite assembled in `load`
425
+ // would hold the icons reached *so far*, which is a different sprite on
426
+ // every run depending on module order.
427
+ async generateBundle(_options, bundle) {
428
+ if (!iconsOn || !spriteRequested) return;
429
+ const sprite = await ensureService().sprite({
430
+ outDir: cacheDir(),
431
+ icons: [...reachedIcons.values()],
432
+ });
433
+ // Inlined into the chunk and *not* emitted as a file of its own. An
434
+ // external sprite would be the better answer if it worked — one file
435
+ // cached across every page — but `<use href="sprite.svg#id">` does not
436
+ // resolve across documents in any version of Safari and is blocked
437
+ // cross-origin in Chrome, so the file would be dead weight in `dist/`
438
+ // and in `uf_bundle`'s size report. `uf assets` still writes it into the
439
+ // cache directory, which is where the dev server reads it from.
440
+ //
441
+ // The placeholder is replaced rather than the module re-run: by
442
+ // `generateBundle` the chunk is already generated, and the sprite is one
443
+ // string literal in it.
444
+ //
445
+ // A function replacement, because a plain string one would interpret
446
+ // `$&` and `$'` — and an icon is somebody else's markup.
447
+ const escaped = JSON.stringify(sprite.markup).slice(1, -1);
448
+ for (const chunk of Object.values(bundle)) {
449
+ if (chunk.type === "chunk" && chunk.code.includes(SPRITE_PLACEHOLDER)) {
450
+ chunk.code = chunk.code.replaceAll(SPRITE_PLACEHOLDER, () => escaped);
451
+ }
452
+ }
453
+ },
454
+
455
+ configureServer(devServer) {
456
+ server = devServer;
457
+ devServer.httpServer?.once("close", () => {
458
+ service?.close();
459
+ service = null;
460
+ });
461
+
462
+ // Before Vite's own middlewares: nothing else knows this prefix, and the
463
+ // files are outside the module graph, so there is nothing to wait for.
464
+ const directory = cacheDir();
465
+ devServer.middlewares.use((request, response, next) => {
466
+ const url = request.url ?? "";
467
+ const at = url.indexOf(DEV_PREFIX);
468
+ if (at === -1) return next();
469
+ const name = decodeURIComponent(url.slice(at + DEV_PREFIX.length).split("?")[0]);
470
+ // The name is a file name and nothing else. Every emitted name is one
471
+ // path segment by construction, so a request carrying a separator is
472
+ // not a name this plugin ever minted — refusing it rather than
473
+ // resolving it is what keeps the cache directory from being a way to
474
+ // read the rest of the disk.
475
+ if (name === "" || name.includes("/") || name.includes("\\") || name.includes("..")) {
476
+ response.statusCode = 400;
477
+ response.end("bad asset name");
478
+ return;
479
+ }
480
+ const target = path.join(directory, name);
481
+ if (!existsSync(target)) return next();
482
+ response.setHeader("Content-Type", contentTypeOf(name));
483
+ // The name is a content hash, so the bytes under it never change.
484
+ response.setHeader("Cache-Control", "public, max-age=31536000, immutable");
485
+ response.end(readFileSync(target));
486
+ });
487
+ },
488
+
489
+ watchChange(id) {
490
+ // The memo below is what stops `uf build` decoding every image twice,
491
+ // once per bundle. In a dev server it would also stop uf ever noticing
492
+ // that an image was edited: Vite invalidates the module and calls `load`
493
+ // again, and `load` would hand back the manifest it made before the
494
+ // change. Dropping both keys is cheap and the next `load` redoes the
495
+ // work — which, because the emitted names are content hashes, writes new
496
+ // files and leaves the old ones for anything still holding a URL.
497
+ transformed.delete(`image:${path.resolve(id)}`);
498
+ transformed.delete(`font:${path.resolve(id)}`);
499
+ transformed.delete(`og:${path.resolve(id)}`);
500
+ },
501
+
502
+ buildEnd() {
503
+ // A dev server keeps its service for the whole session; a build is done
504
+ // with it here. The same rule `uf:flow` follows next door.
505
+ //
506
+ // `transformed` is *not* cleared. A build's second pass over the same
507
+ // modules then needs no process at all — every answer is already in the
508
+ // map, and `ensureService` is never reached.
509
+ if (server == null) {
510
+ service?.close();
511
+ service = null;
512
+ }
513
+ },
514
+ };
515
+ }
516
+
517
+ /**
518
+ * Transform one asset and return the module that stands for it.
519
+ *
520
+ * Split out of the hook so the hook stays readable and so the memoisation is
521
+ * visible: `uf build` runs Vite twice over the same modules, once for the
522
+ * browser bundle and once for the server one, and an image transformed on both
523
+ * passes would be decoded twice for one build.
524
+ */
525
+ async function loadAsset(context) {
526
+ const { kind, file, transformed, service, cacheDir, baseUrl, assetsDir, isBuild, images, fonts } =
527
+ context;
528
+ const key = `${kind}:${file}`;
529
+ let manifest = transformed.get(key);
530
+ if (manifest == null) {
531
+ if (kind === "image") {
532
+ manifest = await service.image(file, {
533
+ outDir: cacheDir,
534
+ widths: images.widths,
535
+ quality: images.quality,
536
+ blur: images.placeholder,
537
+ });
538
+ } else if (kind === "og") {
539
+ manifest = await service.og(file, { outDir: cacheDir });
540
+ } else {
541
+ manifest = await service.font(file, {
542
+ outDir: cacheDir,
543
+ family: fonts.family,
544
+ display: fonts.display,
545
+ subset: fonts.subset,
546
+ preload: fonts.preload,
547
+ baseUrl,
548
+ });
549
+ }
550
+ transformed.set(key, manifest);
551
+ }
552
+
553
+ const files =
554
+ kind === "image"
555
+ ? manifest.variants.map((v) => v.file)
556
+ : kind === "og"
557
+ ? [manifest.file]
558
+ : // Every bucket, not just the primary: a `unicode-range` split emits
559
+ // one file per script and the browser fetches whichever the page
560
+ // needs. Emitting only the one the manifest calls primary would
561
+ // leave the others named in the stylesheet and absent from `dist/`.
562
+ manifest.faces.map((face) => face.file);
563
+ if (isBuild) {
564
+ // Handed to Rollup rather than copied by hand, so the bundler owns what
565
+ // lands in the output directory and `uf_bundle`'s size report — which
566
+ // walks that directory — counts every one of them.
567
+ for (const name of files) {
568
+ this.emitFile({
569
+ type: "asset",
570
+ // `fileName` rather than `name`: the name is already a content hash of
571
+ // the source and the parameters, and letting Rollup hash it again
572
+ // would move it on every encoder change while saying nothing new.
573
+ fileName: `${assetsDir}/${name}`,
574
+ source: readFileSync(path.join(cacheDir, name)),
575
+ });
576
+ }
577
+ }
578
+
579
+ if (kind === "image") {
580
+ return assetModuleSource(withUrls(manifest, baseUrl));
581
+ }
582
+ if (kind === "og") {
583
+ return assetModuleSource({
584
+ // The shape `Metadata.openGraph.images` and `OgImage` both read. A URL,
585
+ // a size and the alt text, which is all a card ever is to a page.
586
+ url: `${baseUrl}${manifest.file}`,
587
+ width: manifest.width,
588
+ height: manifest.height,
589
+ type: manifest.mime,
590
+ alt: manifest.alt,
591
+ bytes: manifest.bytes,
592
+ });
593
+ }
594
+ return assetModuleSource({
595
+ src: `${baseUrl}${manifest.file}`,
596
+ family: manifest.family,
597
+ fallbackFamily: manifest.fallbackFamily,
598
+ // The stack a page should set `font-family` to: the real face, then the
599
+ // metric-matched fallback, then the local face it was scaled from. Written
600
+ // here so no page has to remember that the fallback only ever applies when
601
+ // it is named after the real face.
602
+ fontFamily: [manifest.family, manifest.fallbackFamily, manifest.fallback?.local]
603
+ .filter((name) => name != null)
604
+ .map((name) => JSON.stringify(name))
605
+ .join(", "),
606
+ type: manifest.mime,
607
+ css: manifest.css,
608
+ metrics: manifest.metrics,
609
+ fallback: manifest.fallback,
610
+ fallbackDeclined: manifest.fallbackDeclined,
611
+ // Every emitted file with its URL, so `Font` can preload exactly the one
612
+ // marked rather than all of them — which is the one thing that would undo
613
+ // a `unicode-range` split.
614
+ faces: (manifest.faces ?? []).map((face) => ({
615
+ ...face,
616
+ url: `${baseUrl}${face.file}`,
617
+ })),
618
+ subset: manifest.subset ?? null,
619
+ // Carried through for the same reason `declined` is on an image: a project
620
+ // that asked for a subset and got the whole font is entitled to the
621
+ // sentence saying why, without reading this plugin.
622
+ subsetDeclined: manifest.subsetDeclined ?? null,
623
+ sourceBytes: manifest.sourceBytes ?? null,
624
+ });
625
+ }
626
+
627
+ /** The media type for one emitted file name. */
628
+ function contentTypeOf(name) {
629
+ const extension = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
630
+ const types = {
631
+ avif: "image/avif",
632
+ gif: "image/gif",
633
+ jpg: "image/jpeg",
634
+ jpeg: "image/jpeg",
635
+ otf: "font/otf",
636
+ png: "image/png",
637
+ svg: "image/svg+xml",
638
+ ttf: "font/ttf",
639
+ webp: "image/webp",
640
+ woff: "font/woff",
641
+ woff2: "font/woff2",
642
+ };
643
+ return types[extension] ?? "application/octet-stream";
644
+ }