@uniflowed/web 0.0.0-alpha.18

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/media.js ADDED
@@ -0,0 +1,637 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/web/media`: the elements that load bytes.
4
+ //
5
+ // An image, a `<picture>`, and a font. They are grouped because they share one
6
+ // problem: each one is a request the browser has to make while the page is
7
+ // still being laid out, and each has a well-known way of being got wrong that
8
+ // costs either a layout shift or a wasted download.
9
+ //
10
+ // Every default here is the one that is right more often than not, and every
11
+ // one of them can be overridden — a component whose defaults cannot be turned
12
+ // off is a component people stop using.
13
+ //
14
+ // # These render a pipeline's output, not just markup
15
+ //
16
+ // `Image` and `Font` accept the value an `import` of an image or a font
17
+ // evaluates to. That value is not a URL: `uf:asset` — the Vite plugin in
18
+ // `@uniflowed/vite`, driving `crates/uf_assets` through `uf assets` — decoded
19
+ // the file at build time, wrote a variant at every width the project declares,
20
+ // re-encoded each one in a modern format where the modern format was actually
21
+ // smaller, and measured the font's real metrics. What arrives here is that
22
+ // manifest.
23
+ //
24
+ // Which is what makes these components worth using instead of `<img>` and
25
+ // `@font-face`. A `srcSet` can only be written by something that knows which
26
+ // other sizes exist; a `size-adjust` can only be computed by something that has
27
+ // read the font's `OS/2` table. Neither is knowable from a string, and before
28
+ // the pipeline existed these components had a string and wrote the attribute
29
+ // the author handed them.
30
+ //
31
+ // A string still works, and still means exactly what it used to: this is the
32
+ // escape hatch for an image uf did not build — one from a CMS, one in
33
+ // `public/` — and for it the author supplies `width`, `height` and `srcSet`
34
+ // themselves, as they always did.
35
+ //
36
+ // # What belongs in this module
37
+ //
38
+ // Anything a page renders in order to make the browser fetch bytes while the
39
+ // layout is still being decided: another `<source>` form, a video poster, a
40
+ // preload for a stylesheet. The test is whether the mistake it prevents is a
41
+ // wasted request or a shifted layout.
42
+ //
43
+ // `Font` is the boundary worth stating, because it is a `<link>` and a
44
+ // `<style>` and `<head>` has its own module next door. It is here anyway: what
45
+ // is hard about a font is the fetch — `as`, `crossOrigin`, the silent second
46
+ // download when they disagree, the reflow when the real face lands — and not
47
+ // the tags it happens to be written as. `head.js` is for what a component
48
+ // decides to put in the head; this is for what the browser is about to
49
+ // download.
50
+ //
51
+ // Not here: announcing a change (`regions.js`), agreeing with a server about
52
+ // an instant (`time.js`) or about a stored value (`cookie.js`).
53
+
54
+ import * as React from "@uniflowed/react";
55
+
56
+ /** How an image should be fetched relative to the rest of the page. */
57
+ export type Loading = "eager" | "lazy";
58
+
59
+ /** One alternative rendering of the same image. */
60
+ export type Source = {
61
+ readonly srcSet: string,
62
+ readonly type?: string,
63
+ readonly media?: string,
64
+ };
65
+
66
+ /**
67
+ * What importing an image evaluates to.
68
+ *
69
+ * Produced by `uf:asset`; see `packages/vite/internal/assets.js`. Every field
70
+ * is a fact about files that exist on disk, which is the difference between
71
+ * this and a set of props.
72
+ */
73
+ export type ImageAsset = {
74
+ /** The widest variant in the fallback format — what `src` points at. */
75
+ readonly src: string | null,
76
+ /** The source's intrinsic width, or `null` when uf could not decode it. */
77
+ readonly width: number | null,
78
+ /** The source's intrinsic height. */
79
+ readonly height: number | null,
80
+ /** Every fallback-format variant, as a `srcset` string. */
81
+ readonly srcSet: string,
82
+ /** Modern formats that came out smaller, widest-preferred first. */
83
+ readonly sources: $ReadOnlyArray<Source>,
84
+ /**
85
+ * A tiny inline image to show until the real one lands.
86
+ *
87
+ * `null` for an image with transparency: the placeholder is painted under
88
+ * the image, so under one with holes in it it never goes away.
89
+ */
90
+ readonly blurDataURL: string | null,
91
+ /** Why nothing was resized, when nothing was. */
92
+ readonly note: string | null,
93
+ };
94
+
95
+ /** One file a font import put in the build. */
96
+ export type FontFace = {
97
+ /** Its URL. */
98
+ readonly url: string,
99
+ /** Its media type. */
100
+ readonly mime: string,
101
+ /** Its size in bytes. */
102
+ readonly bytes: number,
103
+ /** Which script bucket it is, when the family was split. */
104
+ readonly bucket: string | null,
105
+ /** The exact `unicode-range` of what is in it. */
106
+ readonly unicodeRange: string | null,
107
+ /**
108
+ * Whether a page should preload this one.
109
+ *
110
+ * At most one face is ever marked. A preload per bucket downloads the whole
111
+ * family up front, which is the one thing a `unicode-range` split exists to
112
+ * stop.
113
+ */
114
+ readonly preload: boolean,
115
+ };
116
+
117
+ /**
118
+ * What importing a font evaluates to.
119
+ *
120
+ * Produced by `uf:asset` from the font's own `head`, `hhea` and `OS/2` tables.
121
+ */
122
+ export type FontAsset = {
123
+ /** The primary self-hosted file's URL. */
124
+ readonly src: string,
125
+ /** The family the `@font-face` declares. */
126
+ readonly family: string,
127
+ /** The metric-matched fallback's family, when there is one. */
128
+ readonly fallbackFamily: string | null,
129
+ /** The whole stack, ready for `font-family`. */
130
+ readonly fontFamily: string,
131
+ /** The file's media type, for the preload. */
132
+ readonly type: string,
133
+ /** The `@font-face` rules: every real face, then the matched fallback. */
134
+ readonly css: string,
135
+ /** Every emitted file, one per bucket when the family was split. */
136
+ readonly faces?: $ReadOnlyArray<FontFace>,
137
+ /** Why the whole font was hosted, when a subset was asked for and refused. */
138
+ readonly subsetDeclined?: string | null,
139
+ };
140
+
141
+ /**
142
+ * What importing an Open Graph template evaluates to.
143
+ *
144
+ * Produced by `uf:asset` from a `*.og.json`, which is a declared template and
145
+ * not a document — see `crates/uf_assets/src/og.rs` for what uf will and will
146
+ * not draw.
147
+ */
148
+ export type OgAsset = {
149
+ /** The card's URL, relative to the site. */
150
+ readonly url: string,
151
+ /** Its width in pixels. */
152
+ readonly width: number,
153
+ /** Its height in pixels. */
154
+ readonly height: number,
155
+ /** Its media type. */
156
+ readonly type: string,
157
+ /** The `og:image:alt` text. */
158
+ readonly alt: string,
159
+ };
160
+
161
+ /** What importing `uf:icon/<name>` evaluates to. */
162
+ export type IconAsset = {
163
+ /** The symbol's id in the sprite. */
164
+ readonly id: string,
165
+ /** The fragment a `<use>` points at. */
166
+ readonly href: string,
167
+ /** The symbol's `viewBox`. */
168
+ readonly viewBox: string,
169
+ /** Its intrinsic width. */
170
+ readonly width: number,
171
+ /** Its intrinsic height. */
172
+ readonly height: number,
173
+ };
174
+
175
+ /** What importing `uf:icon-sprite` evaluates to. */
176
+ export type SpriteAsset = {
177
+ /** The `<svg>` holding one `<symbol>` per icon the build reached. */
178
+ readonly markup: string,
179
+ };
180
+
181
+ /**
182
+ * The `sizes` to use when the author did not say.
183
+ *
184
+ * A `srcset` with `w` descriptors and no `sizes` means `100vw` to the browser,
185
+ * which makes it fetch a variant wide enough for the whole viewport however
186
+ * small the image is drawn — the ladder is built and then the widest rung is
187
+ * taken every time. Capping at the intrinsic width is the honest default: it
188
+ * never asks for more pixels than exist, and it is exactly right for the
189
+ * common case of an image drawn at its natural size.
190
+ *
191
+ * It is still a guess about a layout this component cannot see, so a page that
192
+ * knows better should say so. That is what the prop is for.
193
+ */
194
+ function defaultSizes(width: number): string {
195
+ return `(max-width: ${width}px) 100vw, ${width}px`;
196
+ }
197
+
198
+ /**
199
+ * An `<img>` that does not shift the page while it loads.
200
+ *
201
+ * `width` and `height` are the *intrinsic* dimensions, not a size — CSS still
202
+ * decides how big it looks, and the ratio is what stops the shift. A browser
203
+ * that knows an image's intrinsic ratio reserves the space before the bytes
204
+ * arrive; one that does not reflows everything below when they do.
205
+ *
206
+ * They come from the import when `src` is an imported image, because uf
207
+ * decoded the file and knows them. They are required when `src` is a string,
208
+ * because nothing else does — and a missing dimension throws rather than
209
+ * rendering an `<img>` that will move the page, which is the mistake this
210
+ * component exists to prevent and is not worth failing quietly at.
211
+ *
212
+ * `loading="lazy"` and `decoding="async"` by default, because most images on a
213
+ * page are below the fold and decoding on the main thread blocks it. The one
214
+ * image that *is* above the fold should say `priority`, which makes it eager,
215
+ * gives it a high fetch priority, and preloads it — a `srcSet` behind a lazy
216
+ * `<img>` is discovered late, and for the largest image on the page that is
217
+ * usually the whole of the Largest Contentful Paint.
218
+ */
219
+ export component Image(
220
+ src: string | ImageAsset,
221
+ alt: string,
222
+ width?: number,
223
+ height?: number,
224
+ loading?: Loading,
225
+ priority?: boolean = false,
226
+ sizes?: string,
227
+ srcSet?: string,
228
+ placeholder?: boolean = true,
229
+ className?: string,
230
+ style?: { readonly [string]: string | number },
231
+ ...rest: { readonly [string]: mixed }
232
+ ) renders React.Node {
233
+ // Refined rather than cast: `src` is a union and `typeof` narrows it, so
234
+ // neither branch needs `any`. A cast here would be the one place in this
235
+ // module where the manifest's shape stopped being checked.
236
+ const asset: ImageAsset | null = typeof src === "string" ? null : src;
237
+ const url: string | null = typeof src === "string" ? src : src.src;
238
+ const finalWidth = width ?? asset?.width ?? null;
239
+ const finalHeight = height ?? asset?.height ?? null;
240
+
241
+ if (finalWidth == null || finalHeight == null) {
242
+ // Not a warning. An `<img>` with no intrinsic ratio moves every line below
243
+ // it when it lands, and the whole reason to write `<Image>` rather than
244
+ // `<img>` is that this cannot happen by accident. When uf could not decode
245
+ // the file it says so in `note`, and the fix is to pass the dimensions.
246
+ const because =
247
+ asset?.note != null
248
+ ? ` uf did not measure it: ${asset.note}`
249
+ : " uf has no manifest for a string `src`.";
250
+ throw new Error(
251
+ `<Image src=${JSON.stringify(url ?? "")}> needs \`width\` and \`height\`.${because}`,
252
+ );
253
+ }
254
+ if (url == null) {
255
+ throw new Error("<Image> was given an imported image with no emitted file.");
256
+ }
257
+
258
+ const finalSrcSet = srcSet ?? asset?.srcSet;
259
+ const finalSizes =
260
+ sizes ?? (finalSrcSet != null && finalSrcSet !== "" ? defaultSizes(finalWidth) : undefined);
261
+ const finalLoading: Loading = loading ?? (priority ? "eager" : "lazy");
262
+ const blur = placeholder ? asset?.blurDataURL : null;
263
+
264
+ const image = (
265
+ <img
266
+ src={url}
267
+ alt={alt}
268
+ width={finalWidth}
269
+ height={finalHeight}
270
+ loading={finalLoading}
271
+ // Always async: decoding a large image synchronously blocks the main
272
+ // thread, and nothing on a page needs that.
273
+ decoding="async"
274
+ // Only when it was asked for. `fetchPriority="auto"` is the default and
275
+ // writing it out says nothing; `"high"` on every image says nothing too,
276
+ // because a priority everything has is a priority nothing has.
277
+ fetchPriority={priority ? "high" : undefined}
278
+ sizes={finalSizes}
279
+ srcSet={finalSrcSet}
280
+ className={className}
281
+ style={
282
+ blur == null
283
+ ? style
284
+ : {
285
+ // The placeholder is a background rather than a second `<img>`:
286
+ // it needs no element, it cannot be announced twice to a screen
287
+ // reader, and the browser paints over it as the real image
288
+ // decodes instead of swapping one node for another.
289
+ backgroundImage: `url("${blur}")`,
290
+ backgroundSize: "cover",
291
+ backgroundPosition: "center",
292
+ ...style,
293
+ }
294
+ }
295
+ {...rest}
296
+ />
297
+ );
298
+
299
+ // A preload, for the one image the page is judged on. React hoists it into
300
+ // `<head>` and dedupes by href, so writing it here — beside the image it is
301
+ // about — costs nothing and cannot drift from what the `<img>` asks for:
302
+ // `imageSrcSet` and `imageSizes` have to match, or the preload fetches a
303
+ // different variant than the one the `<img>` then requests and the page pays
304
+ // for both.
305
+ const preload =
306
+ priority === true ? (
307
+ <link
308
+ rel="preload"
309
+ as="image"
310
+ href={url}
311
+ imageSrcSet={finalSrcSet}
312
+ imageSizes={finalSizes}
313
+ fetchPriority="high"
314
+ />
315
+ ) : null;
316
+
317
+ const sources = asset?.sources ?? [];
318
+ if (sources.length === 0) {
319
+ return (
320
+ <>
321
+ {preload}
322
+ {image}
323
+ </>
324
+ );
325
+ }
326
+
327
+ return (
328
+ <>
329
+ {preload}
330
+ <picture>
331
+ {sources.map((source) => (
332
+ <source
333
+ key={`${source.type ?? ""}:${source.media ?? ""}`}
334
+ srcSet={source.srcSet}
335
+ type={source.type}
336
+ media={source.media}
337
+ sizes={finalSizes}
338
+ />
339
+ ))}
340
+ {image}
341
+ </picture>
342
+ </>
343
+ );
344
+ }
345
+
346
+ /**
347
+ * A `<picture>`: the same image, in formats a browser may prefer.
348
+ *
349
+ * For sources a project assembled itself. An imported image needs none of
350
+ * this — `Image` renders the `<picture>` when the pipeline produced an
351
+ * alternative format, and skips it when it did not, which is the difference
352
+ * between offering a format and offering a format that is smaller.
353
+ *
354
+ * The sources are offered in order and the browser takes the first it
355
+ * understands, so put the format you most want served first — `avif`, then
356
+ * `webp`, then whatever `src` is. The `<img>` is the fallback and is not
357
+ * optional: a `<picture>` with no `<img>` renders nothing at all, which is a
358
+ * mistake that only shows up in the one browser that took none of the sources.
359
+ */
360
+ export component Picture(
361
+ src: string,
362
+ alt: string,
363
+ width: number,
364
+ height: number,
365
+ sources?: $ReadOnlyArray<Source> = [],
366
+ loading?: Loading = "lazy",
367
+ className?: string,
368
+ ) renders React.Node {
369
+ return (
370
+ <picture>
371
+ {sources.map((source) => (
372
+ <source
373
+ key={`${source.type ?? ""}:${source.media ?? ""}:${source.srcSet}`}
374
+ srcSet={source.srcSet}
375
+ type={source.type}
376
+ media={source.media}
377
+ />
378
+ ))}
379
+ <Image
380
+ src={src}
381
+ alt={alt}
382
+ width={width}
383
+ height={height}
384
+ loading={loading}
385
+ className={className}
386
+ />
387
+ </picture>
388
+ );
389
+ }
390
+
391
+ /**
392
+ * Self-host a font, declare it, and preload it.
393
+ *
394
+ * # The preload
395
+ *
396
+ * A font referenced only from CSS is discovered late — the browser has to
397
+ * fetch and parse the stylesheet first — so the first paint uses a fallback
398
+ * and the text reflows when the real face lands. A preload moves the fetch to
399
+ * the start of the page.
400
+ *
401
+ * It is also the thing most easily overdone, so exactly one file is ever
402
+ * preloaded: the build marks the face a page paints first, and a family split
403
+ * into eight `unicode-range` buckets still contributes one link. Preloading
404
+ * every bucket downloads the whole family up front, which is what the split
405
+ * existed to stop, and four preloaded faces have pushed the page's own
406
+ * stylesheet down the same connection. Pass `preload={false}` for a face that
407
+ * does not paint the first screen, and `preload={true}` for one the project's
408
+ * `app.builtins.fonts.preload` turned off but this page needs — the prop wins
409
+ * over the build in both directions, and one link is still the most it emits.
410
+ *
411
+ * `crossOrigin` is set unconditionally and deliberately: a font is fetched in
412
+ * CORS mode whatever its origin, so a preload without it is a *second*,
413
+ * separate request rather than the same one — the preload is wasted and the
414
+ * font still arrives late. This is the single most common way to get a font
415
+ * preload wrong, and it fails silently.
416
+ *
417
+ * # The `@font-face`, and the one that stops the reflow
418
+ *
419
+ * Given an imported font, this also emits the rules that declare it. There are
420
+ * two: the real face, and a second face over a font the reader already has,
421
+ * carrying the `size-adjust`, `ascent-override`, `descent-override` and
422
+ * `line-gap-override` uf computed from the real font's own metrics. Scale the
423
+ * fallback until it occupies the same space and the swap moves nothing — which
424
+ * is the half of `font-display: swap` that everybody wants and nobody gets by
425
+ * default.
426
+ *
427
+ * The rules only apply to text that asks for them, so use `fontFamily` from the
428
+ * import: it names the real face, then the matched fallback, then the local
429
+ * face it was scaled from, in that order. Naming only the real family gets the
430
+ * download and none of the metric matching.
431
+ *
432
+ * # One weight per import
433
+ *
434
+ * The generated face is `font-weight: 400; font-style: normal`, and the family
435
+ * is the file's stem: `Inter-Bold.woff2` is the family `Inter-Bold`, not
436
+ * `Inter` at 700, so `font-weight: bold` will not reach it. Render it under its
437
+ * own `fontFamily` instead. A variable font is one file covering the range and
438
+ * is unaffected. `uf assets` accepts `weight` and `style` already; what does
439
+ * not exist yet is a way for an import to say them.
440
+ *
441
+ * Write it beside the text that needs the font, not at the root of the page:
442
+ * React hoists both `<link rel="preload">` and `<style>` into `<head>` itself,
443
+ * and dedupes them, so the component that depends on a face is the one that
444
+ * asks for it and two components asking for the same face still make one
445
+ * request.
446
+ */
447
+ export component Font(
448
+ src: string | FontAsset,
449
+ type?: string,
450
+ crossOrigin?: "anonymous" | "use-credentials" = "anonymous",
451
+ preload?: boolean,
452
+ ) renders React.Node {
453
+ const asset: FontAsset | null = typeof src === "string" ? null : src;
454
+ const url: string = typeof src === "string" ? src : src.src;
455
+ const finalType = type ?? asset?.type ?? "font/woff2";
456
+
457
+ // Which file to preload, and it is never more than one. A font hosted whole
458
+ // has a single face and the build decided whether to preload it; a family
459
+ // split by `unicode-range` has one face marked and the rest deliberately
460
+ // unmarked. A string `src` has no manifest to have decided anything, so it
461
+ // keeps the behaviour it always had.
462
+ const declared = asset?.faces ?? [];
463
+ const all =
464
+ declared.length > 0
465
+ ? declared
466
+ : [{ url, mime: finalType, bytes: 0, bucket: null, unicodeRange: null, preload: true }];
467
+ // The prop overrides the build in both directions: `false` emits no link at
468
+ // all, and `true` emits one for the primary face even when the build left
469
+ // every face unmarked — otherwise `preload` would be a prop that can only
470
+ // ever say no, which is not what a boolean looks like.
471
+ const chosen =
472
+ preload === false
473
+ ? null
474
+ : (all.find((face) => face.preload) ?? (preload === true ? all[0] : null));
475
+
476
+ const links =
477
+ chosen == null
478
+ ? []
479
+ : [
480
+ <link
481
+ key={chosen.url}
482
+ rel="preload"
483
+ as="font"
484
+ href={chosen.url}
485
+ type={chosen.mime}
486
+ crossOrigin={crossOrigin}
487
+ />,
488
+ ];
489
+ if (asset == null) return <>{links}</>;
490
+
491
+ return (
492
+ <>
493
+ {links}
494
+ {/*
495
+ Hoisted and deduped by React, keyed on `href`. The href is the font's
496
+ own content-hashed URL, so two components asking for the same face
497
+ contribute one `<style>` and a project with two faces gets two.
498
+ `precedence` is what makes React hoist it at all; "high" puts the
499
+ `@font-face` rules above the application's own stylesheets, which is
500
+ where a declaration the rest of the sheet refers to belongs.
501
+ */}
502
+ <style href={url} precedence="high">
503
+ {asset.css}
504
+ </style>
505
+ </>
506
+ );
507
+ }
508
+
509
+ /**
510
+ * The sprite holding every icon this build reached.
511
+ *
512
+ * Render it once, near the top of the document. It is a definitions block and
513
+ * not content: it paints nothing, it is `aria-hidden`, and every `<Icon>` on
514
+ * the page is a forty-byte `<use>` pointing into it.
515
+ *
516
+ * The alternative — a component per icon, each carrying its own path data —
517
+ * repeats the same geometry once per use, and a runtime icon library ships
518
+ * every icon it has because at runtime nothing knows which ones the
519
+ * application imported. A build does, which is the whole reason this exists.
520
+ *
521
+ * The markup comes from `uf assets`, which parses each file, refuses the
522
+ * constructs that must not be inlined into a document — `<script>`,
523
+ * `<foreignObject>`, `on…` handlers, `javascript:`, anything fetching from
524
+ * another origin — and namespaces every internal `id` so two icons defining
525
+ * the same gradient do not collide. `dangerouslySetInnerHTML` is the only way
526
+ * to put an already-serialised subtree into the DOM, and what makes it safe
527
+ * here is that the string was produced by uf from files in the repository, not
528
+ * by anything at runtime.
529
+ */
530
+ export component IconSprite(sprite: SpriteAsset) renders React.Node {
531
+ return (
532
+ <div
533
+ // Out of the flow entirely rather than `display: none`: a `<use>` may
534
+ // not resolve into a subtree the browser never laid out, and Safari has
535
+ // historically been the one to enforce it.
536
+ style={{ position: "absolute", width: 0, height: 0, overflow: "hidden" }}
537
+ aria-hidden={true}
538
+ // A sprite is a serialised subtree and `dangerouslySetInnerHTML` is the
539
+ // only way to put one in the DOM: React has no other spelling for
540
+ // "these elements already exist as markup", and rebuilding the symbols
541
+ // as elements would mean parsing SVG in the browser, which is the thing
542
+ // this feature exists to have already done at build time.
543
+ //
544
+ // What the rule is protecting against is a string that reaches this
545
+ // attribute from a request, a form, a database — anything a person can
546
+ // put a `<script>` into. This one cannot be any of those. It is
547
+ // assembled by `uf assets` from files in the repository, and that
548
+ // assembly *refuses* rather than strips: a `<script>`, a
549
+ // `<foreignObject>`, an `on…` attribute, a `javascript:` value or a
550
+ // reference out of the file fails the build with the file named. So the
551
+ // only markup that can arrive here is markup a build already read and
552
+ // accepted, which is the same trust boundary as the rest of the bundle.
553
+ // uf-lint-disable-next-line security/no-dangerously-set-inner-html
554
+ dangerouslySetInnerHTML={{ __html: sprite.markup }}
555
+ />
556
+ );
557
+ }
558
+
559
+ /**
560
+ * One icon out of the sprite.
561
+ *
562
+ * `<Icon icon={star} label="Favourite" />` — with a label when the icon *is*
563
+ * the control, and without one when there is text beside it. That is the whole
564
+ * accessibility decision an icon needs and the one people most often get
565
+ * backwards: an unlabelled icon button is a button a screen reader announces
566
+ * as "button", and a labelled icon next to its own visible text is the same
567
+ * word read twice.
568
+ *
569
+ * `size` sets both dimensions, because an icon whose aspect ratio is not its
570
+ * `viewBox`'s is a squashed icon. Pass `width` and `height` separately only
571
+ * when that is what you mean.
572
+ */
573
+ export component Icon(
574
+ icon: IconAsset,
575
+ label?: string,
576
+ size?: number = 24,
577
+ width?: number,
578
+ height?: number,
579
+ className?: string,
580
+ ...rest: { readonly [string]: mixed }
581
+ ) renders React.Node {
582
+ return (
583
+ <svg
584
+ viewBox={icon.viewBox}
585
+ width={width ?? size}
586
+ height={height ?? size}
587
+ className={className}
588
+ // `role="img"` with a label, nothing without one. An `aria-hidden` icon
589
+ // beside its own text is the correct markup, and `focusable="false"`
590
+ // keeps it out of the tab order in the browsers that put SVGs in it.
591
+ role={label != null ? "img" : undefined}
592
+ aria-label={label}
593
+ aria-hidden={label == null ? true : undefined}
594
+ focusable="false"
595
+ {...rest}
596
+ >
597
+ <use href={icon.href} />
598
+ </svg>
599
+ );
600
+ }
601
+
602
+ /**
603
+ * The `<meta>` tags for one Open Graph card.
604
+ *
605
+ * `<OgImage card={card} />` beside the page that owns it. Three things go
606
+ * wrong with an Open Graph image and this gets all three right: the URL has to
607
+ * be **absolute** — a relative `og:image` is not an Open Graph image at all,
608
+ * and every crawler drops it — the dimensions have to be declared or the card
609
+ * flickers at whatever size the crawler guesses, and `twitter:card` has to say
610
+ * `summary_large_image` or X renders a thumbnail of a 1200x630 picture.
611
+ *
612
+ * `origin` is the site's own, and it is required for exactly the reason above.
613
+ * A project using `@uniflowed/router`'s `Metadata` already has it as
614
+ * `metadataBase` and should pass `card` through `openGraph.images` instead of
615
+ * rendering this — one page's metadata belongs in one place. This component is
616
+ * for a page assembling its own head.
617
+ *
618
+ * The card itself is drawn at build time by `uf assets` from a `*.og.json`
619
+ * template. It is a template and not a renderer: uf will not turn JSX into an
620
+ * image, and it refuses text it cannot lay out rather than drawing it wrong.
621
+ */
622
+ export component OgImage(card: OgAsset, origin: string, alt?: string) renders React.Node {
623
+ const absolute = card.url.startsWith("http")
624
+ ? card.url
625
+ : `${origin.replace(/\/+$/, "")}/${card.url.replace(/^\/+/, "")}`;
626
+ return (
627
+ <>
628
+ <meta property="og:image" content={absolute} />
629
+ <meta property="og:image:width" content={String(card.width)} />
630
+ <meta property="og:image:height" content={String(card.height)} />
631
+ <meta property="og:image:type" content={card.type} />
632
+ <meta property="og:image:alt" content={alt ?? card.alt} />
633
+ <meta name="twitter:card" content="summary_large_image" />
634
+ <meta name="twitter:image" content={absolute} />
635
+ </>
636
+ );
637
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@uniflowed/web",
3
+ "version": "0.0.0-alpha.18",
4
+ "description": "The primitives a web page is built from, for the Unified Toolchain for Flow.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "sideEffects": false,
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/ubugeeei-prod/uf.git",
11
+ "directory": "packages/web"
12
+ },
13
+ "exports": {
14
+ ".": "./index.js",
15
+ "./cookie": "./cookie.js",
16
+ "./head": "./head.js",
17
+ "./media": "./media.js",
18
+ "./regions": "./regions.js",
19
+ "./time": "./time.js",
20
+ "./vitals": "./vitals.js"
21
+ },
22
+ "files": [
23
+ "*.js",
24
+ "!*.test.js"
25
+ ],
26
+ "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"
30
+ }
31
+ }