@uniflowed/vite 0.0.0-alpha.14 → 0.0.0-alpha.15

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/driver.js CHANGED
@@ -10,6 +10,8 @@
10
10
  // <host> driver.js build --root <dir> [--mode <m>] [--out-dir <dir>]
11
11
  // [--prerender everything|possible|nothing]
12
12
  // [--static-build] [--because <sentence>]
13
+ // <host> driver.js library --root <dir> [--mode <m>] [--out-dir <dir>]
14
+ // --entry <file>... --format <es|cjs>... [--external <name>]...
13
15
  // <host> driver.js compile --root <dir> [--mode <m>] [--out-dir <dir>] --assets <file> --bundle <dir>
14
16
  // <host> driver.js deploy --root <dir> [--mode <m>] [--out-dir <dir>] --adapter <name> --work <dir> --output <dir>
15
17
  // <host> driver.js preview --root <dir> [--mode <m>] [--out-dir <dir>] [--host <h>] [--port <n>]
@@ -38,7 +40,7 @@
38
40
  // one host that can evaluate the file evaluates it.
39
41
 
40
42
  import { createServer as createHttpServer } from "node:http";
41
- import { register } from "node:module";
43
+ import { builtinModules, register } from "node:module";
42
44
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
43
45
  import path from "node:path";
44
46
  import { pathToFileURL } from "node:url";
@@ -97,7 +99,7 @@ process.stdin.on("end", () => process.exit(0));
97
99
  process.stdin.on("error", () => process.exit(0));
98
100
  process.stdin.resume();
99
101
 
100
- const commands = { dev, build, compile, deploy, preview, start, config: printConfig };
102
+ const commands = { dev, build, library, compile, deploy, preview, start, config: printConfig };
101
103
  const run = commands[command];
102
104
  if (run == null) {
103
105
  emit("error", { message: `unknown driver command ${JSON.stringify(command)}` });
@@ -680,6 +682,147 @@ async function build() {
680
682
  process.exit(0);
681
683
  }
682
684
 
685
+ /**
686
+ * The library build, for a project whose `app.router.enabled` is false.
687
+ *
688
+ * `build` above is an application build and has no other mode: it links
689
+ * `virtual:uf/client`, which imports the router and the project's `app.js`.
690
+ * A library has neither, so `uf build` in a project `uf create lib`
691
+ * scaffolded failed at the first pass with `Could not resolve '<root>/app.js'`
692
+ * — a file a library does not have and never had. See ubugeeei-prod/uf#268.
693
+ *
694
+ * This is the fourth thing the driver does, beside `dev`, `build` and
695
+ * `compile`, and it is one pass per format over one input list. Which of the
696
+ * two builds runs is **not decided here**: `uf` resolves it from the config
697
+ * (`uf_config`'s `LibraryPlan`) and spawns this subcommand, the same way
698
+ * `--prerender` arrives as one word rather than as two settings for this file
699
+ * to read together.
700
+ *
701
+ * # The three ways it differs from the application build
702
+ *
703
+ * * **Every dependency stays an import.** `--external` names them, and
704
+ * `uf` computes the list from the project's own manifest —
705
+ * `dependencies`, `peerDependencies`, `optionalDependencies` — so a
706
+ * library ships its own modules and nobody else's. That is the opposite
707
+ * of the application build, which inlines what it can because an
708
+ * application is the end of the line and a library is not: a bundled copy
709
+ * of React inside a library is a second React in every application that
710
+ * installs it.
711
+ * * **One output per entry, named after the entry.** `index.js` becomes
712
+ * `dist/index.js`; `internal/parse.js` becomes `dist/internal/parse.js`.
713
+ * The path rather than the basename, so two entries cannot collide at the
714
+ * moment one would overwrite the other.
715
+ * * **No manifest, no prerender, no server bundle.** There is no document to
716
+ * write and no route table to write it from.
717
+ *
718
+ * Vite's own `build.lib` does the work. uf owns *that* a library is a
719
+ * different build and what goes into it; how this builder performs one is the
720
+ * builder's, which is the same line `build` draws around `rollupOptions`.
721
+ */
722
+ async function library() {
723
+ const vite = await import("vite");
724
+ const config = await loadConfig();
725
+ const inline = await viteConfig(config, argument("--mode") ?? "production");
726
+ const outDir = path.resolve(root, inline.build.outDir);
727
+ const entries = argumentAll("--entry");
728
+ const formats = argumentAll("--format");
729
+ const external = argumentAll("--external");
730
+ if (entries.length === 0) {
731
+ throw new Error("uf: `driver.js library` needs at least one --entry");
732
+ }
733
+ if (formats.length === 0) {
734
+ throw new Error("uf: `driver.js library` needs at least one --format");
735
+ }
736
+
737
+ // Keyed by the entry's path without its extension, which is what Vite's lib
738
+ // mode turns into the output file name.
739
+ const input = {};
740
+ for (const entry of entries) {
741
+ input[entryName(entry)] = path.resolve(root, entry);
742
+ }
743
+
744
+ const isExternal = externalTest(external);
745
+ // One pass per format rather than one build with several outputs: Vite's
746
+ // lib mode writes a whole `outDir` per format, and the second pass must not
747
+ // empty what the first wrote. So `emptyOutDir` is true exactly once, on the
748
+ // first, which is also what makes a build that dropped an entry leave no
749
+ // stale copy of it behind.
750
+ let first = true;
751
+ for (const format of formats) {
752
+ emit("phase", { name: `library (${format})` });
753
+ await vite.build({
754
+ ...inline,
755
+ build: {
756
+ ...inline.build,
757
+ // Vite's `manifest` maps source modules to hashed browser assets. A
758
+ // library has neither — its file names are its API — and writing one
759
+ // would put a `.vite/` directory into a published tarball.
760
+ manifest: false,
761
+ outDir,
762
+ emptyOutDir: first,
763
+ lib: {
764
+ entry: input,
765
+ formats: [format],
766
+ fileName: (_format, name) => `${name}.${format === "cjs" ? "cjs" : "js"}`,
767
+ },
768
+ rollupOptions: { external: isExternal },
769
+ },
770
+ });
771
+ first = false;
772
+ }
773
+
774
+ emit("done", { outDir: path.relative(root, outDir), pages: 0 });
775
+ process.exit(0);
776
+ }
777
+
778
+ /**
779
+ * The output name for one entry: its path, without the extension.
780
+ *
781
+ * Not the basename. `index.js` and `internal/index.js` are two entries a
782
+ * library can reasonably have, and under a basename they are one file written
783
+ * twice — the second silently winning, which is a published package whose
784
+ * subpath export is somebody else's module.
785
+ */
786
+ function entryName(entry) {
787
+ const normalised = entry.replace(/\\/g, "/").replace(/^\.\//, "");
788
+ const dot = normalised.lastIndexOf(".");
789
+ const slash = normalised.lastIndexOf("/");
790
+ return dot > slash ? normalised.slice(0, dot) : normalised;
791
+ }
792
+
793
+ /**
794
+ * Whether an import is somebody else's module.
795
+ *
796
+ * Three checks, and only the one over `names` is a policy uf decided. `names`
797
+ * is what `uf` read out of the project's manifest and passed as `--external`,
798
+ * and a subpath of one of those names — `@scope/pkg/deep` for `@scope/pkg` —
799
+ * is the same package. The other two are the host's built-in modules, and they
800
+ * are a fact rather than a decision: `node:fs` has no bytes to inline.
801
+ *
802
+ * A bare relative or absolute id is never external, which is the rule that
803
+ * makes this a library build at all: what the author wrote is bundled, and
804
+ * what they installed is imported.
805
+ */
806
+ function externalTest(names) {
807
+ const declared = new Set(names);
808
+ // The host's built-in module names, unprefixed. `node:`-prefixed ids are
809
+ // caught by the first check whatever the host is; this set is for the bare
810
+ // spellings — `fs`, `path`, `stream` — which a dependency written before the
811
+ // prefix existed still uses. Read from the running host rather than written
812
+ // down, because the list grows and a stale copy of it here would be a
813
+ // bundled `node:worker_threads` that cannot be bundled.
814
+ const builtins = new Set(builtinModules ?? []);
815
+ return (id) => {
816
+ if (id.startsWith("node:")) return true;
817
+ if (builtins.has(id)) return true;
818
+ if (declared.has(id)) return true;
819
+ for (const name of declared) {
820
+ if (id.startsWith(`${name}/`)) return true;
821
+ }
822
+ return false;
823
+ };
824
+ }
825
+
683
826
  /**
684
827
  * Link the whole application into one JavaScript file, for `uf build --compile`.
685
828
  *
@@ -887,6 +1030,16 @@ const SERVERLESS_CAPABILITIES = { module: "@uniflowed/server/lambda", name: "lam
887
1030
  * copying every file in it is bulk work over the whole build, which belongs in
888
1031
  * Rust rather than in the host process — the same division `--compile` makes
889
1032
  * with its embedded assets.
1033
+ *
1034
+ * # `--adapter static` never reaches this function
1035
+ *
1036
+ * It is the one implemented target with no application to link: a static host
1037
+ * returns files, and `uf build` has already written them. So `uf` copies the
1038
+ * output directory itself and never spawns this driver for it, which is why
1039
+ * [`ADAPTERS`] has four rows and not five. What that target does instead of
1040
+ * linking is refuse a project whose route handlers, middleware, unprerendered
1041
+ * routes or server actions a static host cannot answer — in Rust, because the
1042
+ * facts it needs are the route table and what the prerender reported.
890
1043
  */
891
1044
  async function deploy() {
892
1045
  const vite = await import("vite");
package/index.js CHANGED
@@ -79,6 +79,7 @@ import {
79
79
  } from "./internal/routes.js";
80
80
  import { TransformService, isFlowModule } from "@uniflowed/host/transform";
81
81
  import { createChannelMiddleware } from "./internal/diagnostics.js";
82
+ import { devtoolsPreamble } from "./internal/devtools.js";
82
83
  import { send, toRequest } from "./internal/http.js";
83
84
  import { beginRequest } from "./internal/serve.js";
84
85
 
@@ -122,19 +123,26 @@ export default function uniflowed(options = {}) {
122
123
  const appEntry = app.router?.entry ?? ufConfig.build?.entries?.[0] ?? "app.js";
123
124
  const markdown = app.builtins?.markdown ?? {};
124
125
  const builtins = app.builtins ?? {};
126
+ // On unless the project says otherwise, and read as `!== false` rather than
127
+ // `=== true` because that is what "on by default" means for a field almost
128
+ // no `uf.config.js` will mention. It only ever reaches the *development*
129
+ // client entry; see `flowPlugin`'s `load`. ubugeeei-prod/uf#516.
130
+ const strictMode = app.react?.strictMode !== false;
125
131
 
126
132
  return [
127
- flowPlugin({ routerRoot, appEntry, command: options.command }),
133
+ flowPlugin({ routerRoot, appEntry, strictMode, command: options.command }),
128
134
  mdxPlugin(markdown),
129
135
  assetPlugin({
130
136
  images: builtins.images ?? {},
131
137
  fonts: builtins.fonts ?? {},
138
+ icons: builtins.icons ?? {},
139
+ og: builtins.og ?? {},
132
140
  command: options.command,
133
141
  }),
134
142
  ];
135
143
  }
136
144
 
137
- function flowPlugin({ routerRoot, appEntry, command }) {
145
+ function flowPlugin({ routerRoot, appEntry, strictMode, command }) {
138
146
  let root = process.cwd();
139
147
  let isProduction = false;
140
148
  let base = "/";
@@ -305,7 +313,12 @@ function flowPlugin({ routerRoot, appEntry, command }) {
305
313
  if (isSsr(this, loadOptions)) return routesModuleSource(table);
306
314
  return clientRoutesModule(table);
307
315
  }
308
- if (id === resolved(VIRTUAL.client)) return clientModuleSource(entryPath);
316
+ // Strict Mode belongs to the client entry and to development only: a
317
+ // build passes `false`, so the generated module is the one that existed
318
+ // before #516 and a visitor's browser renders once.
319
+ if (id === resolved(VIRTUAL.client)) {
320
+ return clientModuleSource(entryPath, { strictMode: strictMode && !isProduction });
321
+ }
309
322
  if (id === resolved(VIRTUAL.server)) return serverModuleSource(entryPath);
310
323
  // Only `virtual:uf/server` imports this, so it is only ever asked for in
311
324
  // the server environment — but the table it carries is every callable
@@ -404,9 +417,26 @@ function flowPlugin({ routerRoot, appEntry, command }) {
404
417
  }
405
418
  },
406
419
 
420
+ // The two scripts a development document loads before its own, and
421
+ // nothing at all in a build — which is the whole of "the hook is out of a
422
+ // production build" (ubugeeei-prod/uf#503) and of "production does not run
423
+ // under Strict Mode" (#516, whose flag is generated into
424
+ // `virtual:uf/client` rather than injected here).
425
+ //
426
+ // DevTools first, and as a *classic* script rather than a module: React
427
+ // registers itself with `__REACT_DEVTOOLS_GLOBAL_HOOK__` while `react-dom`
428
+ // is evaluated and never again, so the hook has to exist before any module
429
+ // runs. A classic inline script runs while the parser is on it; a module
430
+ // waits for the document. `internal/devtools.js` has the rest of the
431
+ // argument, and the three conditions DevTools needs.
407
432
  transformIndexHtml() {
408
433
  if (isProduction) return [];
409
434
  return [
435
+ {
436
+ tag: "script",
437
+ children: devtoolsPreamble(),
438
+ injectTo: "head-prepend",
439
+ },
410
440
  {
411
441
  tag: "script",
412
442
  attrs: { type: "module" },
@@ -9,7 +9,10 @@
9
9
  // imports", `resolveId` + `load` + `generateBundle` + `writeBundle` +
10
10
  // `transformIndexHtml` — since before there was anything behind it, and
11
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.
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.
13
16
  //
14
17
  // # What an import becomes
15
18
  //
@@ -45,6 +48,19 @@
45
48
  // two must agree": there is one pipeline and one set of bytes, and the only
46
49
  // thing that differs between them is the URL prefix they are served under.
47
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
+ //
48
64
  // # What this plugin deliberately does not claim
49
65
  //
50
66
  // An import with a query — `./hero.png?url`, `?raw`, `?inline` — is left to
@@ -57,7 +73,13 @@
57
73
  import { existsSync, readFileSync } from "node:fs";
58
74
  import path from "node:path";
59
75
 
60
- import { AssetService, assetKind } from "@uniflowed/host/assets";
76
+ import {
77
+ AssetService,
78
+ ICON_PREFIX,
79
+ ICON_SPRITE,
80
+ OG_EXTENSION,
81
+ assetKind,
82
+ } from "@uniflowed/host/assets";
61
83
 
62
84
  /** Where transformed assets are kept, relative to the project root. */
63
85
  export const CACHE_DIR = ".uf/cache/assets";
@@ -71,6 +93,36 @@ export const CACHE_DIR = ".uf/cache/assets";
71
93
  */
72
94
  export const DEV_PREFIX = "@uf-asset/";
73
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
+
74
126
  /**
75
127
  * The module source for one transformed asset.
76
128
  *
@@ -146,15 +198,20 @@ export function withUrls(image, baseUrl) {
146
198
  * @param {object} options
147
199
  * @param {object} [options.images] `app.builtins.images`
148
200
  * @param {object} [options.fonts] `app.builtins.fonts`
201
+ * @param {object} [options.icons] `app.builtins.icons`
202
+ * @param {object} [options.og] `app.builtins.og`
149
203
  * @param {string} [options.command] the `uf` binary to transform through
150
204
  */
151
- export function assetPlugin({ images = {}, fonts = {}, command } = {}) {
205
+ export function assetPlugin({ images = {}, fonts = {}, icons = {}, og = {}, command } = {}) {
152
206
  // Both halves can be turned off independently, and a plugin that is off is
153
207
  // still in the array: `uf inspect` lists the resolved pipeline, and a
154
208
  // pipeline that changes shape when a feature is disabled is a pipeline whose
155
209
  // listing cannot be compared between two projects.
156
210
  const imagesOn = images.enabled !== false;
157
211
  const fontsOn = fonts.enabled !== false;
212
+ const iconsOn = icons.enabled !== false;
213
+ const ogOn = og.enabled !== false;
214
+ const iconDir = icons.dir ?? "icons";
158
215
 
159
216
  let root = process.cwd();
160
217
  let base = "/";
@@ -174,6 +231,18 @@ export function assetPlugin({ images = {}, fonts = {}, command } = {}) {
174
231
  * redo every image, which is the whole cost this map exists to avoid.
175
232
  */
176
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();
177
246
 
178
247
  const cacheDir = () => path.resolve(root, CACHE_DIR);
179
248
  /**
@@ -206,9 +275,32 @@ export function assetPlugin({ images = {}, fonts = {}, command } = {}) {
206
275
  const kind = assetKind(id);
207
276
  if (kind === "image" && !imagesOn) return null;
208
277
  if (kind === "font" && !fontsOn) return null;
278
+ if (kind === "og" && !ogOn) return null;
279
+ if ((kind === "icon" || kind === "sprite") && !iconsOn) return null;
209
280
  return kind;
210
281
  };
211
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
+
212
304
  return {
213
305
  name: "uf:asset",
214
306
  // Before Vite's own asset handling, which would otherwise claim the same
@@ -222,9 +314,93 @@ export function assetPlugin({ images = {}, fonts = {}, command } = {}) {
222
314
  isBuild = config.command === "build";
223
315
  },
224
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
+
225
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
+
226
356
  const kind = claims(id);
227
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
+
228
404
  const file = path.resolve(id);
229
405
  // Not this plugin's to fail on: an id with one of these extensions that
230
406
  // is not a file on disk is a virtual module somebody else owns.
@@ -244,6 +420,38 @@ export function assetPlugin({ images = {}, fonts = {}, command } = {}) {
244
420
  });
245
421
  },
246
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
+
247
455
  configureServer(devServer) {
248
456
  server = devServer;
249
457
  devServer.httpServer?.once("close", () => {
@@ -288,6 +496,7 @@ export function assetPlugin({ images = {}, fonts = {}, command } = {}) {
288
496
  // files and leaves the old ones for anything still holding a URL.
289
497
  transformed.delete(`image:${path.resolve(id)}`);
290
498
  transformed.delete(`font:${path.resolve(id)}`);
499
+ transformed.delete(`og:${path.resolve(id)}`);
291
500
  },
292
501
 
293
502
  buildEnd() {
@@ -319,24 +528,38 @@ async function loadAsset(context) {
319
528
  const key = `${kind}:${file}`;
320
529
  let manifest = transformed.get(key);
321
530
  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
- });
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
+ }
336
550
  transformed.set(key, manifest);
337
551
  }
338
552
 
339
- const files = kind === "image" ? manifest.variants.map((v) => v.file) : [manifest.file];
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);
340
563
  if (isBuild) {
341
564
  // Handed to Rollup rather than copied by hand, so the bundler owns what
342
565
  // lands in the output directory and `uf_bundle`'s size report — which
@@ -356,6 +579,18 @@ async function loadAsset(context) {
356
579
  if (kind === "image") {
357
580
  return assetModuleSource(withUrls(manifest, baseUrl));
358
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
+ }
359
594
  return assetModuleSource({
360
595
  src: `${baseUrl}${manifest.file}`,
361
596
  family: manifest.family,
@@ -373,6 +608,19 @@ async function loadAsset(context) {
373
608
  metrics: manifest.metrics,
374
609
  fallback: manifest.fallback,
375
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,
376
624
  });
377
625
  }
378
626
 
@@ -0,0 +1,117 @@
1
+ // @noflow
2
+ //
3
+ // Plain JavaScript: executed by the host that runs Vite, before any transform.
4
+ //
5
+ // React DevTools, in `uf dev`, on purpose.
6
+ //
7
+ // DevTools does not attach to React. React attaches to *DevTools*: while
8
+ // `react-dom` is being evaluated it looks for `__REACT_DEVTOOLS_GLOBAL_HOOK__`
9
+ // on the global object and registers itself with whatever it finds, once. A
10
+ // hook that arrives after that line has run is a hook no renderer ever sees, so
11
+ // everything below is about one ordering — the hook exists first — and about
12
+ // saying so in a file whose name a person can grep for.
13
+ //
14
+ // # Why this file exists at all, when it worked before it
15
+ //
16
+ // It did work, and by accident. The Fast Refresh preamble calls
17
+ // `injectIntoGlobalHook` (`./refresh-runtime.js`, Meta's runtime as vendored by
18
+ // `@vitejs/plugin-react`), and that function installs a hook when it finds
19
+ // none, because Fast Refresh needs one to decorate. So `uf dev` had a DevTools
20
+ // hook as a side effect of a function whose subject is hot reloading, in a file
21
+ // uf does not own, with nothing anywhere naming DevTools and no test that would
22
+ // notice its absence. The next upgrade of that vendored runtime, or a change to
23
+ // how the preamble is injected, could have taken it away in a diff nobody would
24
+ // read as being about DevTools. See ubugeeei-prod/uf#503.
25
+ //
26
+ // So the hook is installed here, first, deliberately, and `tests/library/
27
+ // devtools.test.js` runs this script's own text against a fake window.
28
+ //
29
+ // # Three things DevTools needs, and what carries each
30
+ //
31
+ // Naming them together, because each is provided somewhere else and each is one
32
+ // edit away from being lost:
33
+ //
34
+ // 1. **The hook, before the renderer.** This module, injected by
35
+ // `packages/vite/index.js`'s `transformIndexHtml` as a *classic* script at
36
+ // the top of the head — see [`devtoolsPreamble`] for why classic.
37
+ // 2. **One copy of the renderer.** `resolve.dedupe: ["react", "react-dom"]`
38
+ // in that same file. Two copies of `react-dom` register two renderers, and
39
+ // DevTools shows the tree of whichever one it heard from — which is the
40
+ // shape of the "multiple renderers concurrently rendering" report.
41
+ // 3. **The development build of it.** `mode` is `development`, so Vite
42
+ // resolves React's development export condition, and `uf transform` is
43
+ // called with `development: true` — which is what emits `jsxDEV` and the
44
+ // `_jsxFileName` beside every element. Against a production build DevTools
45
+ // says so and shows a tree with no props, no hooks and no source.
46
+ //
47
+ // # And out of a production build
48
+ //
49
+ // A production build injects none of this: `transformIndexHtml` returns an
50
+ // empty list unless the plugin is serving. That is the half a person can check
51
+ // on the artefact rather than by reading, and
52
+ // `crates/uf_cli/tests/vite.rs`'s `a_build_ships_no_devtools_hook` does.
53
+ //
54
+ // What it checks for is the *assignment* below rather than the name, and the
55
+ // distinction is worth stating here because the obvious test is wrong: React's
56
+ // own production build mentions `__REACT_DEVTOOLS_GLOBAL_HOOK__` twice, because
57
+ // reading that global is how a deployed React application is attachable at all.
58
+ // React reads it; only an installer writes it. So `window.<hook> =` is what
59
+ // must be absent, and it is absent because this function is never called
60
+ // outside a dev server.
61
+
62
+ /**
63
+ * The global React registers itself with.
64
+ *
65
+ * Written once, here, so that every other mention of it in uf — the injected
66
+ * script below, the assertion that a build has none — is this constant rather
67
+ * than a fourth spelling of a name whose whole value is that it matches
68
+ * React's exactly.
69
+ */
70
+ export const DEVTOOLS_HOOK = "__REACT_DEVTOOLS_GLOBAL_HOOK__";
71
+
72
+ /**
73
+ * The script every document loads before anything else in development.
74
+ *
75
+ * # A classic script, not a module
76
+ *
77
+ * Everything else uf injects is `type="module"`, and a module script is
78
+ * deferred: it runs after the document has been parsed, in document order with
79
+ * the other modules. That would still be early enough today, because the client
80
+ * entry is also a module and comes later — but "early enough as long as nobody
81
+ * adds a script above it" is exactly the accident this file exists to end. A
82
+ * classic inline script runs while the parser is on it, so no module, no
83
+ * import, and no `<script src>` a project's own Vite plugin injects can get
84
+ * between this and the renderer.
85
+ *
86
+ * # It never replaces a hook that is already there
87
+ *
88
+ * The DevTools extension installs its hook at `document_start`, which is before
89
+ * any script in the document, so on a machine that has DevTools the branch
90
+ * below is not taken and the extension's hook is what React registers with.
91
+ * Overwriting it would be the one way this file could break the thing it exists
92
+ * to support: the extension holds the connection to the panel, and a stub in
93
+ * its place is a page DevTools can see and never hear from.
94
+ *
95
+ * What is installed when there is nothing to leave alone is the minimum a
96
+ * renderer will register with — `renderers`, `supportsFiber`, `inject` and the
97
+ * three commit callbacks. It reports nothing to anybody: there is no panel, and
98
+ * the point of installing it is that `react-dom` takes the branch where a hook
99
+ * exists, so Fast Refresh has one to decorate and DevTools opened *later* in
100
+ * the same page finds a renderer already registered rather than a page that has
101
+ * to be reloaded. It is the same shape `injectIntoGlobalHook` installs, because
102
+ * it is the same contract; the difference is that this is uf saying so.
103
+ */
104
+ export function devtoolsPreamble() {
105
+ return `(function () {
106
+ if (window.${DEVTOOLS_HOOK} != null) return;
107
+ var nextID = 0;
108
+ window.${DEVTOOLS_HOOK} = {
109
+ renderers: new Map(),
110
+ supportsFiber: true,
111
+ inject: function () { return nextID++; },
112
+ onScheduleFiberRoot: function () {},
113
+ onCommitFiberRoot: function () {},
114
+ onCommitFiberUnmount: function () {},
115
+ };
116
+ })();`;
117
+ }
@@ -16,8 +16,11 @@
16
16
  //
17
17
  // * `POST /__uf/diagnostic` — a diagnostic a browser-side runtime produced
18
18
  // and wants a person to read. `@uniflowed/router`'s `internal/diagnostics.js`
19
- // is the client half, and the hydration-mismatch report beside it is what
20
- // calls it.
19
+ // is the client half; the hydration-mismatch report beside it is what calls
20
+ // it, and so is `internal/devtools.js`, which reads back after hydration
21
+ // whether React DevTools can attach to this page at all
22
+ // (ubugeeei-prod/uf#503). One channel and not one per feature is the whole
23
+ // point: a second endpoint would be a second thing to notice.
21
24
  // * `POST /__uf/vitals` — the five numbers `@uniflowed/web/vitals`
22
25
  // measures, posted by `vitalsBeacon()`. In production a project points the
23
26
  // beacon at an endpoint of its own; in development there was nothing at
@@ -790,12 +790,28 @@ export default routes;
790
790
  * The current route's modules are loaded *before* hydration so the first
791
791
  * render is synchronous and matches the server's HTML; a lazy import during
792
792
  * hydration would suspend and React would fall back to a client render.
793
+ *
794
+ * # Strict Mode is a generated constant, not a runtime check
795
+ *
796
+ * `strictMode` is written into this module as a literal, so a production build
797
+ * gets `hydrate({ … })` with the argument absent and Rollup has nothing to
798
+ * decide. It would have been shorter to have `hydrate` read `import.meta.hot`
799
+ * — the way `client.js` gates the hydration reporter — and that would have been
800
+ * one signal answering two questions: `uf.config.js` can turn Strict Mode off
801
+ * (ubugeeei-prod/uf#516) and `import.meta.hot` cannot be told about it. A
802
+ * project that sets `app.react.strictMode: false` gets a dev server that
803
+ * hydrates the way its deployment does, which is the whole of the escape
804
+ * hatch.
805
+ *
806
+ * @param {string} appEntry the project's `app.js`, as an import specifier
807
+ * @param {{ strictMode?: boolean }} [options]
793
808
  */
794
- export function clientModuleSource(appEntry) {
809
+ export function clientModuleSource(appEntry, options = {}) {
810
+ const strictMode = options.strictMode === true ? ", strictMode: true" : "";
795
811
  return `import { hydrate } from "@uniflowed/router/client";
796
812
  import { routes, notFound, errors } from ${JSON.stringify(VIRTUAL.routes)};
797
813
  import App from ${JSON.stringify(appEntry)};
798
- hydrate({ App, routes, notFound, errors });
814
+ hydrate({ App, routes, notFound, errors${strictMode} });
799
815
  `;
800
816
  }
801
817
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/vite",
3
- "version": "0.0.0-alpha.14",
3
+ "version": "0.0.0-alpha.15",
4
4
  "description": "Vite, driven by uf.config.js: every Flow module through `uf transform`, MDX, the file-system router and static rendering as Vite plugins.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,8 +33,8 @@
33
33
  "dependencies": {
34
34
  "@mdx-js/rollup": "^3.1.1",
35
35
  "@shikijs/rehype": "^3.23.0",
36
- "@uniflowed/host": "0.0.0-alpha.14",
37
- "@uniflowed/server": "0.0.0-alpha.14",
36
+ "@uniflowed/host": "0.0.0-alpha.15",
37
+ "@uniflowed/server": "0.0.0-alpha.15",
38
38
  "rehype-slug": "^6.0.0",
39
39
  "remark-frontmatter": "^5.0.0",
40
40
  "remark-gfm": "^4.0.1",