@uniflowed/web 0.0.0-alpha.18 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cookie.js CHANGED
@@ -49,7 +49,7 @@ const SERVER_COOKIES: string = "__ufServerCookies";
49
49
  * sharing it would mean importing that package here.
50
50
  */
51
51
  export function parse(header: string | null): { [string]: string } {
52
- const out: { [string]: string } = Object.create(null);
52
+ const out: { [string]: string } = Object.setPrototypeOf({}, null);
53
53
  if (header == null || header === "") {
54
54
  return out;
55
55
  }
@@ -93,7 +93,7 @@ export function serialize(name: string, value: string, options?: CookieOptions):
93
93
 
94
94
  /** Whatever cookies are readable here, server or browser. */
95
95
  function readAll(): { [string]: string } {
96
- const host = (globalThis: $FlowFixMe);
96
+ const host = globalThis as $FlowFixMe;
97
97
  if (host.document != null) {
98
98
  return parse(host.document.cookie);
99
99
  }
@@ -123,7 +123,7 @@ export function useCookie(
123
123
 
124
124
  const write = React.useCallback(
125
125
  (next: string, options?: CookieOptions) => {
126
- const host = (globalThis: $FlowFixMe);
126
+ const host = globalThis as $FlowFixMe;
127
127
  if (host.document == null) {
128
128
  throw new Error(
129
129
  `useCookie: ${name} cannot be written during a server render. ` +
package/head.js CHANGED
@@ -80,7 +80,7 @@ export function useHead(head: Head): void {
80
80
  const key = JSON.stringify(head);
81
81
 
82
82
  React.useEffect(() => {
83
- const document = (globalThis: $FlowFixMe).document;
83
+ const document = (globalThis as $FlowFixMe).document;
84
84
  if (document == null) {
85
85
  return undefined;
86
86
  }
@@ -124,7 +124,7 @@ export function useHead(head: Head): void {
124
124
  document.title = previousTitle;
125
125
  }
126
126
  for (const element of added) {
127
- (element: $FlowFixMe).remove();
127
+ (element as $FlowFixMe).remove();
128
128
  }
129
129
  };
130
130
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -0,0 +1,41 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/web`: where `Image` sends a remote `src`.
4
+ //
5
+ // This file is what `Image` reads *outside* a uf build — under `uf test`, in a
6
+ // library that renders one without uf's Vite plugins — and there it says there
7
+ // is no endpoint, so a remote `src` is written into `<img>` as it was given.
8
+ //
9
+ // Inside one, `uf:asset` (`@uniflowed/vite`'s `internal/assets.js`) answers
10
+ // for this module with the project's own values: the endpoint's path under the
11
+ // base path when `app.builtins.images.remotePatterns` lists anything, and the
12
+ // widths and quality the endpoint will accept. Generated rather than read at
13
+ // runtime, because the browser bundle and the server one both render `Image`
14
+ // and have to write the same `srcset`, or hydration disagrees about it.
15
+ //
16
+ // Which is also why nothing but the value lives here: the whole module is
17
+ // replaced, so a function beside it would vanish from the build. What `Image`
18
+ // does with the value is `./remote-image.js`.
19
+
20
+ /** What `Image` needs to know about `/__uf/image`. */
21
+ export type ImageEndpoint = {|
22
+ /** The endpoint's path, or `null` when the project has none. */
23
+ readonly path: string | null,
24
+ /** The widths it accepts, which are the only ones worth a `srcset` rung. */
25
+ readonly widths: $ReadOnlyArray<number>,
26
+ /** The quality it encodes at when a request names none. */
27
+ readonly quality: number,
28
+ |};
29
+
30
+ /**
31
+ * No endpoint, and uf's defaults for the rest.
32
+ *
33
+ * The widths and quality are `uf_assets`' defaults, and
34
+ * `tests/library/image-endpoint.test.js` holds them to the Rust ones and to
35
+ * `@uniflowed/server/image`'s.
36
+ */
37
+ export const IMAGE_ENDPOINT: ImageEndpoint = {
38
+ path: null,
39
+ widths: [640, 750, 828, 1080, 1200, 1440, 1920],
40
+ quality: 75,
41
+ };
@@ -0,0 +1,42 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/web`: the `src` and `srcset` `Image` writes for a
4
+ // remote image, from what `./image-endpoint.js` says about the endpoint.
5
+
6
+ import type { ImageEndpoint } from "./image-endpoint.js";
7
+
8
+ /**
9
+ * The `src` and `srcset` for a remote image, through the endpoint.
10
+ *
11
+ * One rung per accepted width narrower than the image, and one more — the
12
+ * narrowest accepted width that covers it, or the widest there is — described
13
+ * at the width it will actually come back at, because the endpoint never
14
+ * upscales and a descriptor wider than the file is a browser choosing a rung
15
+ * for pixels that do not exist.
16
+ *
17
+ * `null` when the project has no endpoint, or the URL is not one it fetches.
18
+ */
19
+ export function remoteSources(
20
+ endpoint: ImageEndpoint,
21
+ src: string,
22
+ intrinsic: number,
23
+ quality: number,
24
+ ): {| readonly src: string, readonly srcSet: string |} | null {
25
+ const path = endpoint.path;
26
+ if (path == null) return null;
27
+ const scheme = src.slice(0, 8).toLowerCase();
28
+ if (!scheme.startsWith("https://") && !scheme.startsWith("http://")) return null;
29
+ const widths = [...endpoint.widths].sort((a, b) => a - b);
30
+ if (widths.length === 0) return null;
31
+ const at = (width: number): string =>
32
+ `${path}?url=${encodeURIComponent(src)}&w=${width}&q=${quality}`;
33
+ const cover = widths.find((width) => width >= intrinsic) ?? widths[widths.length - 1];
34
+ // Everything narrower than the rung that covers the image is narrower than
35
+ // the image too, so each of those comes back at its own width.
36
+ const rungs = widths.filter((width) => width < cover).map((width) => [width, width]);
37
+ rungs.push([cover, Math.min(cover, intrinsic)]);
38
+ return {
39
+ src: at(cover),
40
+ srcSet: rungs.map(([asked, described]) => `${at(asked)} ${described}w`).join(", "),
41
+ };
42
+ }
package/media.js CHANGED
@@ -33,6 +33,13 @@
33
33
  // `public/` — and for it the author supplies `width`, `height` and `srcSet`
34
34
  // themselves, as they always did.
35
35
  //
36
+ // With one exception, which a project opts into: when
37
+ // `app.builtins.images.remotePatterns` lists hosts, an absolute `http(s)` URL
38
+ // is routed through `/__uf/image` — `@uniflowed/server/image` — which fetches,
39
+ // resizes and re-encodes it on request. `Image` then writes the `srcset` for
40
+ // it, one rung per width the endpoint accepts, because now something does
41
+ // know which sizes exist. `unoptimized` keeps one image out of it.
42
+ //
36
43
  // # What belongs in this module
37
44
  //
38
45
  // Anything a page renders in order to make the browser fetch bytes while the
@@ -52,6 +59,10 @@
52
59
  // an instant (`time.js`) or about a stored value (`cookie.js`).
53
60
 
54
61
  import * as React from "@uniflowed/react";
62
+ import type * as ReactTypes from "react";
63
+
64
+ import { IMAGE_ENDPOINT } from "./internal/image-endpoint.js";
65
+ import { remoteSources } from "./internal/remote-image.js";
55
66
 
56
67
  /** How an image should be fetched relative to the rest of the page. */
57
68
  export type Loading = "eager" | "lazy";
@@ -66,7 +77,7 @@ export type Source = {
66
77
  /**
67
78
  * What importing an image evaluates to.
68
79
  *
69
- * Produced by `uf:asset`; see `packages/vite/internal/assets.js`. Every field
80
+ * Produced by `uf:asset`; see `npm/vite/internal/assets.js`. Every field
70
81
  * is a fact about files that exist on disk, which is the difference between
71
82
  * this and a set of props.
72
83
  */
@@ -215,6 +226,14 @@ function defaultSizes(width: number): string {
215
226
  * gives it a high fetch priority, and preloads it — a `srcSet` behind a lazy
216
227
  * `<img>` is discovered late, and for the largest image on the page that is
217
228
  * usually the whole of the Largest Contentful Paint.
229
+ *
230
+ * A remote `src` — an absolute `http(s)` URL — is sent through `/__uf/image`
231
+ * when the project lists remote hosts in `app.builtins.images.remotePatterns`:
232
+ * `src` and `srcSet` then name the endpoint, one rung per width it accepts,
233
+ * at `quality` or the project's own. `width` and `height` are still required,
234
+ * because the endpoint is asked for the image only after the page has laid it
235
+ * out. A `srcSet` of the author's own, or `unoptimized`, leaves the URL as it
236
+ * was written.
218
237
  */
219
238
  export component Image(
220
239
  src: string | ImageAsset,
@@ -226,10 +245,12 @@ export component Image(
226
245
  sizes?: string,
227
246
  srcSet?: string,
228
247
  placeholder?: boolean = true,
248
+ quality?: number,
249
+ unoptimized?: boolean = false,
229
250
  className?: string,
230
251
  style?: { readonly [string]: string | number },
231
- ...rest: { readonly [string]: mixed }
232
- ) renders React.Node {
252
+ ...rest: ReactTypes.ElementConfig<"img">
253
+ ) {
233
254
  // Refined rather than cast: `src` is a union and `typeof` narrows it, so
234
255
  // neither branch needs `any`. A cast here would be the one place in this
235
256
  // module where the manifest's shape stopped being checked.
@@ -255,7 +276,14 @@ export component Image(
255
276
  throw new Error("<Image> was given an imported image with no emitted file.");
256
277
  }
257
278
 
258
- const finalSrcSet = srcSet ?? asset?.srcSet;
279
+ // A remote URL goes through `/__uf/image` when the project has one, the
280
+ // author wrote no `srcSet` of their own, and did not opt this image out.
281
+ const remote =
282
+ asset == null && srcSet == null && !unoptimized
283
+ ? remoteSources(IMAGE_ENDPOINT, url, finalWidth, quality ?? IMAGE_ENDPOINT.quality)
284
+ : null;
285
+ const finalUrl = remote?.src ?? url;
286
+ const finalSrcSet = srcSet ?? remote?.srcSet ?? asset?.srcSet;
259
287
  const finalSizes =
260
288
  sizes ?? (finalSrcSet != null && finalSrcSet !== "" ? defaultSizes(finalWidth) : undefined);
261
289
  const finalLoading: Loading = loading ?? (priority ? "eager" : "lazy");
@@ -263,7 +291,8 @@ export component Image(
263
291
 
264
292
  const image = (
265
293
  <img
266
- src={url}
294
+ {...rest}
295
+ src={finalUrl}
267
296
  alt={alt}
268
297
  width={finalWidth}
269
298
  height={finalHeight}
@@ -286,13 +315,12 @@ export component Image(
286
315
  // it needs no element, it cannot be announced twice to a screen
287
316
  // reader, and the browser paints over it as the real image
288
317
  // decodes instead of swapping one node for another.
289
- backgroundImage: `url("${blur}")`,
290
- backgroundSize: "cover",
291
- backgroundPosition: "center",
292
318
  ...style,
319
+ backgroundImage: style?.backgroundImage ?? `url("${blur}")`,
320
+ backgroundSize: style?.backgroundSize ?? "cover",
321
+ backgroundPosition: style?.backgroundPosition ?? "center",
293
322
  }
294
323
  }
295
- {...rest}
296
324
  />
297
325
  );
298
326
 
@@ -307,7 +335,7 @@ export component Image(
307
335
  <link
308
336
  rel="preload"
309
337
  as="image"
310
- href={url}
338
+ href={finalUrl}
311
339
  imageSrcSet={finalSrcSet}
312
340
  imageSizes={finalSizes}
313
341
  fetchPriority="high"
@@ -365,7 +393,7 @@ export component Picture(
365
393
  sources?: $ReadOnlyArray<Source> = [],
366
394
  loading?: Loading = "lazy",
367
395
  className?: string,
368
- ) renders React.Node {
396
+ ) {
369
397
  return (
370
398
  <picture>
371
399
  {sources.map((source) => (
@@ -449,7 +477,7 @@ export component Font(
449
477
  type?: string,
450
478
  crossOrigin?: "anonymous" | "use-credentials" = "anonymous",
451
479
  preload?: boolean,
452
- ) renders React.Node {
480
+ ) {
453
481
  const asset: FontAsset | null = typeof src === "string" ? null : src;
454
482
  const url: string = typeof src === "string" ? src : src.src;
455
483
  const finalType = type ?? asset?.type ?? "font/woff2";
@@ -527,7 +555,7 @@ export component Font(
527
555
  * here is that the string was produced by uf from files in the repository, not
528
556
  * by anything at runtime.
529
557
  */
530
- export component IconSprite(sprite: SpriteAsset) renders React.Node {
558
+ export component IconSprite(sprite: SpriteAsset) {
531
559
  return (
532
560
  <div
533
561
  // Out of the flow entirely rather than `display: none`: a `<use>` may
@@ -577,10 +605,11 @@ export component Icon(
577
605
  width?: number,
578
606
  height?: number,
579
607
  className?: string,
580
- ...rest: { readonly [string]: mixed }
581
- ) renders React.Node {
608
+ ...rest: ReactTypes.ElementConfig<"svg">
609
+ ) {
582
610
  return (
583
611
  <svg
612
+ {...rest}
584
613
  viewBox={icon.viewBox}
585
614
  width={width ?? size}
586
615
  height={height ?? size}
@@ -592,7 +621,6 @@ export component Icon(
592
621
  aria-label={label}
593
622
  aria-hidden={label == null ? true : undefined}
594
623
  focusable="false"
595
- {...rest}
596
624
  >
597
625
  <use href={icon.href} />
598
626
  </svg>
@@ -619,7 +647,7 @@ export component Icon(
619
647
  * template. It is a template and not a renderer: uf will not turn JSX into an
620
648
  * image, and it refuses text it cannot lay out rather than drawing it wrong.
621
649
  */
622
- export component OgImage(card: OgAsset, origin: string, alt?: string) renders React.Node {
650
+ export component OgImage(card: OgAsset, origin: string, alt?: string) {
623
651
  const absolute = card.url.startsWith("http")
624
652
  ? card.url
625
653
  : `${origin.replace(/\/+$/, "")}/${card.url.replace(/^\/+/, "")}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/web",
3
- "version": "0.0.0-alpha.18",
3
+ "version": "0.14.1",
4
4
  "description": "The primitives a web page is built from, for the Unified Toolchain for Flow.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -8,7 +8,7 @@
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "git+https://github.com/ubugeeei-prod/uf.git",
11
- "directory": "packages/web"
11
+ "directory": "npm/web"
12
12
  },
13
13
  "exports": {
14
14
  ".": "./index.js",
@@ -21,11 +21,12 @@
21
21
  },
22
22
  "files": [
23
23
  "*.js",
24
+ "internal/*.js",
24
25
  "!*.test.js"
25
26
  ],
26
27
  "dependencies": {
27
- "@uniflowed/core": "0.0.0-alpha.18",
28
- "@uniflowed/hooks": "0.0.0-alpha.18",
29
- "@uniflowed/react": "0.0.0-alpha.18"
28
+ "@uniflowed/core": "0.14.1",
29
+ "@uniflowed/hooks": "0.14.1",
30
+ "@uniflowed/react": "0.14.1"
30
31
  }
31
32
  }
package/regions.js CHANGED
@@ -75,7 +75,7 @@ export component Announcer(
75
75
  children?: React.Node,
76
76
  urgency?: "polite" | "assertive" = "polite",
77
77
  atomic?: boolean = true,
78
- ) renders React.Node {
78
+ ) {
79
79
  return (
80
80
  <div aria-live={urgency} aria-atomic={atomic} style={VISUALLY_HIDDEN}>
81
81
  {children}
@@ -90,7 +90,7 @@ export component Announcer(
90
90
  * landmark list offers, and there must be exactly one per document — which is
91
91
  * why this is not something a layout should also render.
92
92
  */
93
- export component Page(children?: React.Node, className?: string) renders React.Node {
93
+ export component Page(children?: React.Node, className?: string) {
94
94
  return (
95
95
  <main id={MAIN_ID} className={className}>
96
96
  {children}
@@ -110,7 +110,7 @@ export component Page(children?: React.Node, className?: string) renders React.N
110
110
  * it takes focus and then shows itself, which is why it does not cost a
111
111
  * design anything: the only person who ever sees it is the person using it.
112
112
  */
113
- export component SkipLink(children?: React.Node = "Skip to content") renders React.Node {
113
+ export component SkipLink(children?: React.Node = "Skip to content") {
114
114
  const [focused, setFocused] = React.useState(false);
115
115
 
116
116
  return (
@@ -150,6 +150,6 @@ export component SkipLink(children?: React.Node = "Skip to content") renders Rea
150
150
  * inside it — `<nav>`, `<main>`, `<footer>` — are the ones a reader navigates
151
151
  * by, and wrapping them in another one adds a level to walk through.
152
152
  */
153
- export component Layout(children?: React.Node, className?: string) renders React.Node {
153
+ export component Layout(children?: React.Node, className?: string) {
154
154
  return <div className={className}>{children}</div>;
155
155
  }
package/time.js CHANGED
@@ -159,7 +159,7 @@ export component Time(
159
159
  zone?: string,
160
160
  locale?: string,
161
161
  className?: string,
162
- ) renders React.Node {
162
+ ) {
163
163
  const at = asInstant(value);
164
164
  const machine = at.toString();
165
165
  const rendered = useRenderEnvelope();
@@ -171,6 +171,9 @@ export component Time(
171
171
 
172
172
  React.useEffect(() => {
173
173
  if (format === "local") {
174
+ // The local zone is a browser value; the server text stays deterministic
175
+ // until the client can replace it after hydration.
176
+ // uf-lint-disable-next-line react-compiler/set-state-in-effect
174
177
  setText(at.toZonedDateTimeISO(Temporal.Now.timeZoneId()).toLocaleString(locale));
175
178
  } else if (format === "relative") {
176
179
  setText(relative(at));