polite-media 0.3.0
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/CHANGELOG.md +113 -0
- package/LICENSE +21 -0
- package/README.md +675 -0
- package/dist/coordinator.d.ts +231 -0
- package/dist/coordinator.d.ts.map +1 -0
- package/dist/coordinator.js +1017 -0
- package/dist/coordinator.js.map +1 -0
- package/dist/env.d.ts +30 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +49 -0
- package/dist/env.js.map +1 -0
- package/dist/events.d.ts +70 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +42 -0
- package/dist/events.js.map +1 -0
- package/dist/image.css +1 -0
- package/dist/image.d.ts +45 -0
- package/dist/image.d.ts.map +1 -0
- package/dist/image.js +127 -0
- package/dist/image.js.map +1 -0
- package/dist/layer.css +1 -0
- package/dist/reveal.d.ts +34 -0
- package/dist/reveal.d.ts.map +1 -0
- package/dist/reveal.js +72 -0
- package/dist/reveal.js.map +1 -0
- package/dist/sources.d.ts +22 -0
- package/dist/sources.d.ts.map +1 -0
- package/dist/sources.js +148 -0
- package/dist/sources.js.map +1 -0
- package/dist/targets.d.ts +20 -0
- package/dist/targets.d.ts.map +1 -0
- package/dist/targets.js +17 -0
- package/dist/targets.js.map +1 -0
- package/dist/video.css +1 -0
- package/dist/video.d.ts +15 -0
- package/dist/video.d.ts.map +1 -0
- package/dist/video.js +17 -0
- package/dist/video.js.map +1 -0
- package/dist/warm.d.ts +13 -0
- package/dist/warm.d.ts.map +1 -0
- package/dist/warm.js +12 -0
- package/dist/warm.js.map +1 -0
- package/dist/warming.d.ts +62 -0
- package/dist/warming.d.ts.map +1 -0
- package/dist/warming.js +136 -0
- package/dist/warming.js.map +1 -0
- package/package.json +96 -0
- package/src/coordinator.ts +1337 -0
- package/src/env.ts +56 -0
- package/src/events.ts +78 -0
- package/src/image.css +74 -0
- package/src/image.ts +160 -0
- package/src/layer.css +60 -0
- package/src/reveal.ts +75 -0
- package/src/sources.ts +164 -0
- package/src/targets.ts +27 -0
- package/src/video.css +74 -0
- package/src/video.ts +32 -0
- package/src/warm.ts +12 -0
- package/src/warming.ts +162 -0
package/src/targets.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* How both halves name the elements they act on, so `revealImages('.card img')`
|
|
4
|
+
* and `registerAll('[data-polite-media] video')` mean the same thing rather than
|
|
5
|
+
* being two similar-looking ideas.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Anything that names one or more elements: a selector, a single element, or any
|
|
10
|
+
* collection of them.
|
|
11
|
+
*
|
|
12
|
+
* `ArrayLike` is listed alongside `Iterable` deliberately. `NodeListOf` and
|
|
13
|
+
* `HTMLCollectionOf` are iterable at runtime, but their `[Symbol.iterator]` lives
|
|
14
|
+
* in `lib.dom.iterable`, so a consumer whose `lib` omits it cannot pass
|
|
15
|
+
* `document.querySelectorAll('img')` to an `Iterable`-only parameter even though
|
|
16
|
+
* it works. `ArrayLike` is structural and needs no `lib` support.
|
|
17
|
+
*/
|
|
18
|
+
export type Target<T extends Element> = string | T | ArrayLike<T> | Iterable<T>;
|
|
19
|
+
|
|
20
|
+
/** Resolves a {@link Target} to the elements it names. */
|
|
21
|
+
export function resolveTargets<T extends Element>(target: Target<T>): T[] {
|
|
22
|
+
if (typeof target === 'string') return [...document.querySelectorAll<T>(target)];
|
|
23
|
+
// A single element is the obvious thing to pass when you already hold one, and
|
|
24
|
+
// it used to be rejected: `revealImages(myImg)` did not compile.
|
|
25
|
+
if (target instanceof Element) return [target as T];
|
|
26
|
+
return Array.from(target as ArrayLike<T>);
|
|
27
|
+
}
|
package/src/video.css
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* polite-media/video.css -- optional stylesheet.
|
|
3
|
+
*
|
|
4
|
+
* Timing only. This file sets no size, position or aspect ratio, because the
|
|
5
|
+
* library owns *when* media appears and the host owns *where*. That separation
|
|
6
|
+
* is why it works at any video size, and it would be undone by a single width
|
|
7
|
+
* declaration here.
|
|
8
|
+
*
|
|
9
|
+
* `data-polite-media` goes on the container in your markup, not from script.
|
|
10
|
+
* It has to be authored: a <video preload="none"> paints an empty box, and if
|
|
11
|
+
* the base "hidden" state only arrived once JS ran, a page whose bundle failed
|
|
12
|
+
* would show that empty box stacked over the poster. Authored, the safe state is
|
|
13
|
+
* the default and the library only ever has to reveal.
|
|
14
|
+
*
|
|
15
|
+
* Expected shape -- poster first, video second, sharing one box:
|
|
16
|
+
*
|
|
17
|
+
* <div class="whatever-you-like" data-polite-media>
|
|
18
|
+
* <img src="poster.avif" alt="">
|
|
19
|
+
* <video muted loop playsinline preload="none">
|
|
20
|
+
* <source src="hero.mp4" type="video/mp4">
|
|
21
|
+
* </video>
|
|
22
|
+
* </div>
|
|
23
|
+
*
|
|
24
|
+
* Attributes the library sets on that container:
|
|
25
|
+
* data-polite-ready a frame has actually painted
|
|
26
|
+
* data-polite-failed no source could be decoded; the poster is now permanent
|
|
27
|
+
* And on <html>:
|
|
28
|
+
* data-polite-paused the user stopped playback via a [data-polite-pause-control] control
|
|
29
|
+
*
|
|
30
|
+
* --polite-fade defaults to 0s here, meaning a cut. A frame-0 poster does not
|
|
31
|
+
* make a fade free: playback starts at the reveal, so the poster stays frozen
|
|
32
|
+
* while the video advances and the crossfade blends a still against a frame that
|
|
33
|
+
* has moved on, which reads as a double exposure. Cutting is safe because the
|
|
34
|
+
* reveal waits for a presented frame, so there is always something to cut to.
|
|
35
|
+
*
|
|
36
|
+
* image.css reads the same property but defaults to 350ms, because a lone image
|
|
37
|
+
* has no second moving picture to ghost against and its fade covers the flicker
|
|
38
|
+
* of an async decode landing. Set the property to override both at once.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
[data-polite-media] > video {
|
|
42
|
+
opacity: 0;
|
|
43
|
+
transition: opacity var(--polite-fade, 0s) ease;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
[data-polite-media][data-polite-ready] > video {
|
|
47
|
+
opacity: 1;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/*
|
|
51
|
+
* The poster is hidden only after the crossfade finishes. Removing it up front
|
|
52
|
+
* would expose whatever sits behind mid-dissolve; `visibility` rather than
|
|
53
|
+
* `display` so nothing reflows, and the delay must track --polite-fade.
|
|
54
|
+
*/
|
|
55
|
+
[data-polite-media][data-polite-ready] > :is(img, picture) {
|
|
56
|
+
visibility: hidden;
|
|
57
|
+
transition: visibility 0s linear var(--polite-fade, 0s);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/*
|
|
61
|
+
* A failed video is never revealed, so this is belt and braces -- but a
|
|
62
|
+
* half-decoded frame stuck at opacity 1 is exactly the state a viewer would
|
|
63
|
+
* report as broken.
|
|
64
|
+
*/
|
|
65
|
+
[data-polite-media][data-polite-failed] > video {
|
|
66
|
+
opacity: 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
@media (prefers-reduced-motion: reduce) {
|
|
70
|
+
[data-polite-media] > video,
|
|
71
|
+
[data-polite-media] > :is(img, picture) {
|
|
72
|
+
transition: none;
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/video.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* Everything for background video. Imported on its own so an image-only page
|
|
4
|
+
* never pays for the IntersectionObserver, source selection or arbitration.
|
|
5
|
+
*
|
|
6
|
+
* The export list is deliberately small. Every name here is permanent once
|
|
7
|
+
* published, and adding one later is not a breaking change while removing one
|
|
8
|
+
* is -- so anything without a caller that can be named stays internal. The
|
|
9
|
+
* environment gates and the reveal primitive are used by this package and are
|
|
10
|
+
* not part of its interface.
|
|
11
|
+
*/
|
|
12
|
+
export {
|
|
13
|
+
configure,
|
|
14
|
+
pauseAll,
|
|
15
|
+
register,
|
|
16
|
+
registerAll,
|
|
17
|
+
resumeAll,
|
|
18
|
+
unregister,
|
|
19
|
+
unregisterAll,
|
|
20
|
+
} from './coordinator.js';
|
|
21
|
+
export type { AtOnce, ConfigureOptions, RegisterOptions, VideoTarget } from './coordinator.js';
|
|
22
|
+
|
|
23
|
+
// Loaded for its `declare global` block as much as for the constants: the
|
|
24
|
+
// ElementEventMap and DocumentEventMap augmentation only reaches a consumer if
|
|
25
|
+
// this module is part of their program.
|
|
26
|
+
export {
|
|
27
|
+
POLITE_VIDEO_PAUSECHANGE,
|
|
28
|
+
POLITE_VIDEO_FAILED,
|
|
29
|
+
POLITE_VIDEO_READY,
|
|
30
|
+
type PolitePauseEventDetail,
|
|
31
|
+
type PoliteVideoEventDetail,
|
|
32
|
+
} from './events.js';
|
package/src/warm.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* Warming a destination page's image before the visitor gets there. Imported on
|
|
4
|
+
* its own, so a page that only warms never pays for the video coordinator.
|
|
5
|
+
*
|
|
6
|
+
* A barrel for the same reason `video.ts` is one: this file is what the `./warm`
|
|
7
|
+
* subpath resolves to, so every name it exports is public and permanent once
|
|
8
|
+
* published. `resetWarmed` clears the dedup record for a test and has no caller
|
|
9
|
+
* outside the suite, so it stays in `warming.ts` where tests reach it directly.
|
|
10
|
+
*/
|
|
11
|
+
export { warm, warmOnIntent } from './warming.js';
|
|
12
|
+
export type { WarmOptions, WarmSource } from './warming.js';
|
package/src/warming.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* Warming a destination page's image before the visitor gets there, so the click
|
|
4
|
+
* lands on a picture that is already in the cache.
|
|
5
|
+
*
|
|
6
|
+
* Every document prefetcher fetches the HTML and stops: Astro's `data-astro-prefetch`,
|
|
7
|
+
* Next's `<Link>`, quicklink. The image inside that HTML is discovered only after
|
|
8
|
+
* the new document parses, which is exactly when it is too late to matter.
|
|
9
|
+
*
|
|
10
|
+
* The platform has the pieces but not in one place. `imagesrcset` and `imagesizes`
|
|
11
|
+
* do responsive selection, but MDN scopes them to `rel="preload"` with `as="image"`
|
|
12
|
+
* only, and scopes preload itself to resources "your page will need very soon". A
|
|
13
|
+
* speculative navigation wants `prefetch` semantics, where those attributes do not
|
|
14
|
+
* apply. So you can have the right selection or the right timing, not both, and
|
|
15
|
+
* anyone who wants both ends up re-implementing the browser's own rules in JS:
|
|
16
|
+
* parsing `sizes`, comparing `w` descriptors, guessing format support.
|
|
17
|
+
*
|
|
18
|
+
* This module does none of that. It builds the candidates as a detached
|
|
19
|
+
* `<picture>` and lets the browser choose, which is measurably the same algorithm
|
|
20
|
+
* the destination page will run (e2e/warm.spec.ts, all three engines). Nothing
|
|
21
|
+
* here parses a media query, so nothing here can disagree with one.
|
|
22
|
+
*
|
|
23
|
+
* A detached `<img>` also *fetches* what it selects, so no `<link>` is injected at
|
|
24
|
+
* all. That sidesteps the two documented failures of the link approach: Safari
|
|
25
|
+
* does not support `<link rel="prefetch">`, and Firefox aborts it with
|
|
26
|
+
* NS_BINDING_ABORTED when the response carries no explicit cache header.
|
|
27
|
+
*
|
|
28
|
+
* Not an image pipeline. It generates no URLs and knows no widths; it warms the
|
|
29
|
+
* candidates you already build.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { connectionAllowsMedia } from './env.js';
|
|
33
|
+
|
|
34
|
+
/** One `<picture>` candidate: a format, and the variants available in it. */
|
|
35
|
+
export interface WarmSource {
|
|
36
|
+
/** A MIME type such as `image/avif`. Omitted means "always a candidate". */
|
|
37
|
+
type?: string;
|
|
38
|
+
srcset: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface WarmOptions {
|
|
42
|
+
/** Ordered, first supported wins, exactly as `<source>` children behave. */
|
|
43
|
+
sources?: WarmSource[];
|
|
44
|
+
srcset?: string;
|
|
45
|
+
/** A single URL, for an image with no variants. */
|
|
46
|
+
src?: string;
|
|
47
|
+
/** Handed to the browser verbatim. Nothing in this package parses it. */
|
|
48
|
+
sizes?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const warmed = new Set<string>();
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Images with a fetch still in flight.
|
|
55
|
+
*
|
|
56
|
+
* A detached element is reachable from nothing once this function returns, and a
|
|
57
|
+
* collected image would be an aborted fetch. Precautionary rather than a fix for
|
|
58
|
+
* something observed: no engine was seen dropping one. It is cheap, and the bug
|
|
59
|
+
* it forecloses would be silent and load dependent, which is the kind no test
|
|
60
|
+
* here would catch.
|
|
61
|
+
*/
|
|
62
|
+
const inFlight = new Set<HTMLImageElement>();
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Deduped on what the caller asked for rather than on the URL the browser picks,
|
|
66
|
+
* because the URL is not known until selection has already happened, and the
|
|
67
|
+
* repeat this exists to stop is the same link hovered twice.
|
|
68
|
+
*/
|
|
69
|
+
function keyOf(options: WarmOptions): string {
|
|
70
|
+
const sources = (options.sources ?? []).map((source) => `${source.type ?? ''}|${source.srcset}`);
|
|
71
|
+
// Joined on NUL rather than a newline: a srcset spanning several lines is
|
|
72
|
+
// ordinary formatting, and would otherwise let two different candidate sets
|
|
73
|
+
// build the same key and silently drop the second warm.
|
|
74
|
+
return [...sources, options.srcset ?? '', options.src ?? '', options.sizes ?? ''].join('\0');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Warms one image: selects the variant this browser would request, and fetches it. */
|
|
78
|
+
export function warm(options: WarmOptions): void {
|
|
79
|
+
// Speculative bytes are the first thing to drop on a metered connection. Astro
|
|
80
|
+
// downgrades to its `tap` strategy here rather than skipping; for an image
|
|
81
|
+
// nobody has asked for yet, not spending them at all is the better trade.
|
|
82
|
+
if (!connectionAllowsMedia()) return;
|
|
83
|
+
|
|
84
|
+
const sources = options.sources ?? [];
|
|
85
|
+
// Checked before anything is built, so an empty call allocates nothing, and
|
|
86
|
+
// before `warmed` is touched, so it cannot make a later correct call a no-op.
|
|
87
|
+
if (sources.length === 0 && !options.srcset && !options.src) {
|
|
88
|
+
console.warn(
|
|
89
|
+
'polite-media: warm() was given no src, srcset or sources, so nothing was warmed.'
|
|
90
|
+
);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const key = keyOf(options);
|
|
95
|
+
if (warmed.has(key)) return;
|
|
96
|
+
|
|
97
|
+
const picture = document.createElement('picture');
|
|
98
|
+
for (const source of sources) {
|
|
99
|
+
const element = document.createElement('source');
|
|
100
|
+
if (source.type) element.type = source.type;
|
|
101
|
+
element.srcset = source.srcset;
|
|
102
|
+
picture.append(element);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const img = document.createElement('img');
|
|
106
|
+
picture.append(img);
|
|
107
|
+
// The visitor has not asked for this image and may never ask. It must not
|
|
108
|
+
// compete with the page they are actually looking at.
|
|
109
|
+
img.fetchPriority = 'low';
|
|
110
|
+
if (options.sizes) img.sizes = options.sizes;
|
|
111
|
+
if (options.srcset) img.srcset = options.srcset;
|
|
112
|
+
if (options.src) img.src = options.src;
|
|
113
|
+
|
|
114
|
+
warmed.add(key);
|
|
115
|
+
inFlight.add(img);
|
|
116
|
+
const settled = (): void => void inFlight.delete(img);
|
|
117
|
+
img.addEventListener('load', settled, { once: true });
|
|
118
|
+
img.addEventListener('error', settled, { once: true });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The events that mean "about to navigate". `pointerover` and `focusin` cover
|
|
123
|
+
* Astro's documented `hover` strategy, "when you hover over or focus on the
|
|
124
|
+
* link"; `touchstart` covers the touch case, where a hover never happens.
|
|
125
|
+
*/
|
|
126
|
+
const INTENT_EVENTS = ['pointerover', 'focusin', 'touchstart'] as const;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Warms whatever `resolve` names when the visitor shows intent toward `selector`.
|
|
130
|
+
*
|
|
131
|
+
* Delegated on the document, so links added later are covered without rebinding
|
|
132
|
+
* and one listener serves a whole grid. `resolve` returns the candidates rather
|
|
133
|
+
* than the library inventing a `data-*` vocabulary for them: where an app keeps
|
|
134
|
+
* its srcsets is the app's business.
|
|
135
|
+
*
|
|
136
|
+
* Returns a teardown. Listeners on `document` survive a `<ClientRouter />` swap,
|
|
137
|
+
* so a caller that re-binds per navigation stacks duplicates without one.
|
|
138
|
+
*/
|
|
139
|
+
export function warmOnIntent(
|
|
140
|
+
selector: string,
|
|
141
|
+
resolve: (element: Element) => WarmOptions | null | undefined
|
|
142
|
+
): () => void {
|
|
143
|
+
const onIntent = (event: Event): void => {
|
|
144
|
+
const element = (event.target as Element | null)?.closest?.(selector);
|
|
145
|
+
if (!element) return;
|
|
146
|
+
const options = resolve(element);
|
|
147
|
+
if (options) warm(options);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
for (const type of INTENT_EVENTS) {
|
|
151
|
+
document.addEventListener(type, onIntent, { passive: true });
|
|
152
|
+
}
|
|
153
|
+
return () => {
|
|
154
|
+
for (const type of INTENT_EVENTS) document.removeEventListener(type, onIntent);
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Drops the dedup record so a test can warm the same thing twice. Not public API. */
|
|
159
|
+
export function resetWarmed(): void {
|
|
160
|
+
warmed.clear();
|
|
161
|
+
inFlight.clear();
|
|
162
|
+
}
|