@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/cookie.js +144 -0
- package/head.js +132 -0
- package/index.js +96 -0
- package/media.js +637 -0
- package/package.json +31 -0
- package/regions.js +155 -0
- package/time.js +187 -0
- package/vitals.js +721 -0
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
|
+
}
|