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.
Files changed (60) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/LICENSE +21 -0
  3. package/README.md +675 -0
  4. package/dist/coordinator.d.ts +231 -0
  5. package/dist/coordinator.d.ts.map +1 -0
  6. package/dist/coordinator.js +1017 -0
  7. package/dist/coordinator.js.map +1 -0
  8. package/dist/env.d.ts +30 -0
  9. package/dist/env.d.ts.map +1 -0
  10. package/dist/env.js +49 -0
  11. package/dist/env.js.map +1 -0
  12. package/dist/events.d.ts +70 -0
  13. package/dist/events.d.ts.map +1 -0
  14. package/dist/events.js +42 -0
  15. package/dist/events.js.map +1 -0
  16. package/dist/image.css +1 -0
  17. package/dist/image.d.ts +45 -0
  18. package/dist/image.d.ts.map +1 -0
  19. package/dist/image.js +127 -0
  20. package/dist/image.js.map +1 -0
  21. package/dist/layer.css +1 -0
  22. package/dist/reveal.d.ts +34 -0
  23. package/dist/reveal.d.ts.map +1 -0
  24. package/dist/reveal.js +72 -0
  25. package/dist/reveal.js.map +1 -0
  26. package/dist/sources.d.ts +22 -0
  27. package/dist/sources.d.ts.map +1 -0
  28. package/dist/sources.js +148 -0
  29. package/dist/sources.js.map +1 -0
  30. package/dist/targets.d.ts +20 -0
  31. package/dist/targets.d.ts.map +1 -0
  32. package/dist/targets.js +17 -0
  33. package/dist/targets.js.map +1 -0
  34. package/dist/video.css +1 -0
  35. package/dist/video.d.ts +15 -0
  36. package/dist/video.d.ts.map +1 -0
  37. package/dist/video.js +17 -0
  38. package/dist/video.js.map +1 -0
  39. package/dist/warm.d.ts +13 -0
  40. package/dist/warm.d.ts.map +1 -0
  41. package/dist/warm.js +12 -0
  42. package/dist/warm.js.map +1 -0
  43. package/dist/warming.d.ts +62 -0
  44. package/dist/warming.d.ts.map +1 -0
  45. package/dist/warming.js +136 -0
  46. package/dist/warming.js.map +1 -0
  47. package/package.json +96 -0
  48. package/src/coordinator.ts +1337 -0
  49. package/src/env.ts +56 -0
  50. package/src/events.ts +78 -0
  51. package/src/image.css +74 -0
  52. package/src/image.ts +160 -0
  53. package/src/layer.css +60 -0
  54. package/src/reveal.ts +75 -0
  55. package/src/sources.ts +164 -0
  56. package/src/targets.ts +27 -0
  57. package/src/video.css +74 -0
  58. package/src/video.ts +32 -0
  59. package/src/warm.ts +12 -0
  60. package/src/warming.ts +162 -0
@@ -0,0 +1,148 @@
1
+ import { mediaQuery } from './env.js';
2
+ /**
3
+ * Picks which `<source>` a video actually loads, and moves on when one turns out
4
+ * to be undecodable.
5
+ *
6
+ * Two problems, one mechanism.
7
+ *
8
+ * `<source media>` is evaluated once, during resource selection, and never
9
+ * again -- in every engine, not just Safari. Verified in Chromium: after loading
10
+ * at 1200px, resizing to 500px left `currentSrc` on the wide source even though
11
+ * the narrow query then matched. The spec has no hook for it, since resource
12
+ * selection only re-runs when `src` or the `<source>` children change. So a
13
+ * source list alone cannot respond to a viewport that moved since parse.
14
+ * Resolving the list here and assigning `video.src` directly sidesteps that
15
+ * entirely: one assignment, no negotiation left to go stale.
16
+ *
17
+ * And `canPlayType` is only ever a claim. The HTML Standard sets the bar at the
18
+ * user agent being "confident that the type represents a media resource that it
19
+ * can render", and confidence is not a guarantee: in this project's own fixtures
20
+ * Chromium answered "probably" for AV1 and then failed at
21
+ * `dav1d_send_data()`. So the codec check only drops the flat "no" answers;
22
+ * among what survives, document order sets the try order and the `error` event
23
+ * decides the outcome.
24
+ *
25
+ * Deliberately *not* re-selecting on resize. Reassigning `src` restarts
26
+ * playback from frame 0, so a phone rotating mid-scroll would visibly rewind the
27
+ * video. The first choice sticks for the page's lifetime.
28
+ */
29
+ /** `MediaError.MEDIA_ERR_DECODE` and `MEDIA_ERR_SRC_NOT_SUPPORTED`. */
30
+ const MEDIA_ERR_DECODE = 3;
31
+ const MEDIA_ERR_SRC_NOT_SUPPORTED = 4;
32
+ /**
33
+ * Whether an error means "this file is unusable, try another" as opposed to
34
+ * "something happened to this attempt".
35
+ *
36
+ * Codes matter here. `MEDIA_ERR_ABORTED` (1) is what the element reports when
37
+ * its `src` is reassigned -- which this module does itself -- so treating every
38
+ * error as fatal would cascade through the whole candidate list on the first
39
+ * assignment. `MEDIA_ERR_NETWORK` (2) means the fetch failed on a resource that
40
+ * was previously fine; another candidate is unlikely to fare better on the same
41
+ * connection, and burning the list would leave nothing to retry with.
42
+ */
43
+ export function isUnusable(error) {
44
+ if (!error)
45
+ return false;
46
+ return error.code === MEDIA_ERR_DECODE || error.code === MEDIA_ERR_SRC_NOT_SUPPORTED;
47
+ }
48
+ let warnedMediaGap = false;
49
+ /**
50
+ * Which `<source>` this video could actually load, best first.
51
+ *
52
+ * `media` is treated as a *preference*, not an exclusion. Two queries meant to
53
+ * partition the viewport often do not quite meet: `(max-width: 50rem)` beside
54
+ * `(min-width: 50.001rem)` leaves 0.016px matching neither at a 16px root, and
55
+ * a root font-size that is not 16px moves the boundary somewhere else again.
56
+ * Widths in the gap select nothing and the video has nothing to play. The
57
+ * browser behaves that way too, which is no help to anyone.
58
+ *
59
+ * So when no source claims the current viewport, every decodable one is a
60
+ * candidate and document order decides. The alternative was a markup rule, "the
61
+ * last <source> must carry no media attribute", which every consumer had to
62
+ * remember and which a real project had already got wrong.
63
+ *
64
+ * The cost is that `media` can no longer mean "and otherwise play nothing". It
65
+ * never reliably could, since the markup contract already required an
66
+ * unconditional fallback, and `atOnce: { small: 0 }` says that properly.
67
+ */
68
+ function candidatesFor(video) {
69
+ // `:scope >` so a <source> belonging to some nested media element is never
70
+ // mistaken for this one's.
71
+ const sources = [...video.querySelectorAll(':scope > source')];
72
+ const decodable = sources.filter((source) => {
73
+ // The attribute, not the property: `.src` resolves against the document
74
+ // base, so `<source src="">` comes back as the page's own URL -- truthy,
75
+ // and it would then be handed to the video as if it were a media file.
76
+ if (!source.getAttribute('src'))
77
+ return false;
78
+ // Empty string is the only definite "no" canPlayType offers. "maybe" is
79
+ // kept, because "maybe" is what browsers say about most things.
80
+ return !source.type || video.canPlayType(source.type) !== '';
81
+ });
82
+ const claiming = decodable.filter((source) => {
83
+ const media = source.getAttribute('media');
84
+ return !media || mediaQuery(media).matches;
85
+ });
86
+ if (claiming.length === 0 && decodable.length > 0)
87
+ warnMediaGap(video);
88
+ return {
89
+ declared: sources.length,
90
+ playable: (claiming.length > 0 ? claiming : decodable).map((source) => source.src),
91
+ };
92
+ }
93
+ /**
94
+ * Fires only when the gap actually opens, rather than whenever the markup looks
95
+ * capable of it. A static check would have to nag every page whose sources are
96
+ * all conditional, including the overwhelming majority whose queries really do
97
+ * cover every width they will ever see.
98
+ */
99
+ function warnMediaGap(video) {
100
+ if (warnedMediaGap)
101
+ return;
102
+ warnedMediaGap = true;
103
+ console.warn('polite-media: no <source> claims this viewport, so the first decodable one was used ' +
104
+ 'instead of nothing. Give the last <source> no media attribute to choose the fallback ' +
105
+ 'yourself.', video);
106
+ }
107
+ export function manageSources(video) {
108
+ const { declared, playable: candidates } = candidatesFor(video);
109
+ let index = -1;
110
+ const load = () => {
111
+ const next = candidates[index];
112
+ if (next === undefined)
113
+ return false;
114
+ video.src = next;
115
+ // Required: assigning `src` alone does not restart resource selection on an
116
+ // element that has already loaded.
117
+ video.load();
118
+ return true;
119
+ };
120
+ return {
121
+ select() {
122
+ // "No <source> children at all" and "sources were declared but none
123
+ // qualified" are different situations and must not share a branch. The
124
+ // first is a video authored with a plain `src` -- somebody else's
125
+ // arrangement, left exactly as it is. The second is a video with nothing
126
+ // playable, which has to report failure so the poster stays and the host
127
+ // is told.
128
+ if (declared === 0)
129
+ return Boolean(video.currentSrc || video.getAttribute('src'));
130
+ // Re-checked rather than assumed: a first select() that found nothing
131
+ // playable still advanced the index, so answering a later call with a bare
132
+ // `true` would report success for a video that never loaded anything.
133
+ if (index >= 0)
134
+ return candidates[index] !== undefined;
135
+ index = 0;
136
+ return load();
137
+ },
138
+ advance() {
139
+ index += 1;
140
+ return load();
141
+ },
142
+ };
143
+ }
144
+ /** Internal reset for tests. Not exported from the package entry point. */
145
+ export function resetSourceWarnings() {
146
+ warnedMediaGap = false;
147
+ }
148
+ //# sourceMappingURL=sources.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sources.js","sourceRoot":"","sources":["../src/sources.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,20 @@
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
+ * Anything that names one or more elements: a selector, a single element, or any
9
+ * collection of them.
10
+ *
11
+ * `ArrayLike` is listed alongside `Iterable` deliberately. `NodeListOf` and
12
+ * `HTMLCollectionOf` are iterable at runtime, but their `[Symbol.iterator]` lives
13
+ * in `lib.dom.iterable`, so a consumer whose `lib` omits it cannot pass
14
+ * `document.querySelectorAll('img')` to an `Iterable`-only parameter even though
15
+ * it works. `ArrayLike` is structural and needs no `lib` support.
16
+ */
17
+ export type Target<T extends Element> = string | T | ArrayLike<T> | Iterable<T>;
18
+ /** Resolves a {@link Target} to the elements it names. */
19
+ export declare function resolveTargets<T extends Element>(target: Target<T>): T[];
20
+ //# sourceMappingURL=targets.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"targets.d.ts","sourceRoot":"","sources":["../src/targets.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;;;;;;;GASG;AACH,MAAM,MAAM,MAAM,CAAC,CAAC,SAAS,OAAO,IAAI,MAAM,GAAG,CAAC,GAAG,SAAS,CAAC,CAAC,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,CAAC;AAEhF,0DAA0D;AAC1D,wBAAgB,cAAc,CAAC,CAAC,SAAS,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,CAMxE"}
@@ -0,0 +1,17 @@
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
+ /** Resolves a {@link Target} to the elements it names. */
8
+ export function resolveTargets(target) {
9
+ if (typeof target === 'string')
10
+ return [...document.querySelectorAll(target)];
11
+ // A single element is the obvious thing to pass when you already hold one, and
12
+ // it used to be rejected: `revealImages(myImg)` did not compile.
13
+ if (target instanceof Element)
14
+ return [target];
15
+ return Array.from(target);
16
+ }
17
+ //# sourceMappingURL=targets.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"targets.js","sourceRoot":"","sources":["../src/targets.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAcH,0DAA0D;AAC1D,MAAM,UAAU,cAAc,CAAoB,MAAiB;IACjE,IAAI,OAAO,MAAM,KAAK,QAAQ;QAAE,OAAO,CAAC,GAAG,QAAQ,CAAC,gBAAgB,CAAI,MAAM,CAAC,CAAC,CAAC;IACjF,+EAA+E;IAC/E,iEAAiE;IACjE,IAAI,MAAM,YAAY,OAAO;QAAE,OAAO,CAAC,MAAW,CAAC,CAAC;IACpD,OAAO,KAAK,CAAC,IAAI,CAAC,MAAsB,CAAC,CAAC;AAC5C,CAAC"}
package/dist/video.css ADDED
@@ -0,0 +1 @@
1
+ [data-polite-media]>video{opacity:0;transition:opacity var(--polite-fade, 0s) ease}[data-polite-media][data-polite-ready]>video{opacity:1}[data-polite-media][data-polite-ready]>:is(img,picture){visibility:hidden;transition:visibility 0s linear var(--polite-fade, 0s)}[data-polite-media][data-polite-failed]>video{opacity:0}@media(prefers-reduced-motion:reduce){[data-polite-media]>video,[data-polite-media]>:is(img,picture){transition:none}}
@@ -0,0 +1,15 @@
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 { configure, pauseAll, register, registerAll, resumeAll, unregister, unregisterAll, } from './coordinator.js';
13
+ export type { AtOnce, ConfigureOptions, RegisterOptions, VideoTarget } from './coordinator.js';
14
+ export { POLITE_VIDEO_PAUSECHANGE, POLITE_VIDEO_FAILED, POLITE_VIDEO_READY, type PolitePauseEventDetail, type PoliteVideoEventDetail, } from './events.js';
15
+ //# sourceMappingURL=video.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"video.d.ts","sourceRoot":"","sources":["../src/video.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,EACL,SAAS,EACT,QAAQ,EACR,QAAQ,EACR,WAAW,EACX,SAAS,EACT,UAAU,EACV,aAAa,GACd,MAAM,kBAAkB,CAAC;AAC1B,YAAY,EAAE,MAAM,EAAE,gBAAgB,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAK/F,OAAO,EACL,wBAAwB,EACxB,mBAAmB,EACnB,kBAAkB,EAClB,KAAK,sBAAsB,EAC3B,KAAK,sBAAsB,GAC5B,MAAM,aAAa,CAAC"}
package/dist/video.js ADDED
@@ -0,0 +1,17 @@
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 { configure, pauseAll, register, registerAll, resumeAll, unregister, unregisterAll, } from './coordinator.js';
13
+ // Loaded for its `declare global` block as much as for the constants: the
14
+ // ElementEventMap and DocumentEventMap augmentation only reaches a consumer if
15
+ // this module is part of their program.
16
+ export { POLITE_VIDEO_PAUSECHANGE, POLITE_VIDEO_FAILED, POLITE_VIDEO_READY, } from './events.js';
17
+ //# sourceMappingURL=video.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"video.js","sourceRoot":"","sources":["../src/video.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,EACL,SAAS,EACT,QAAQ,EACR,QAAQ,EACR,WAAW,EACX,SAAS,EACT,UAAU,EACV,aAAa,GACd,MAAM,kBAAkB,CAAC;AAG1B,0EAA0E;AAC1E,+EAA+E;AAC/E,wCAAwC;AACxC,OAAO,EACL,wBAAwB,EACxB,mBAAmB,EACnB,kBAAkB,GAGnB,MAAM,aAAa,CAAC"}
package/dist/warm.d.ts ADDED
@@ -0,0 +1,13 @@
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';
13
+ //# sourceMappingURL=warm.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"warm.d.ts","sourceRoot":"","sources":["../src/warm.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAAE,IAAI,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAClD,YAAY,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC"}
package/dist/warm.js 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
+ //# sourceMappingURL=warm.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"warm.js","sourceRoot":"","sources":["../src/warm.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAAE,IAAI,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC"}
@@ -0,0 +1,62 @@
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
+ /** One `<picture>` candidate: a format, and the variants available in it. */
32
+ export interface WarmSource {
33
+ /** A MIME type such as `image/avif`. Omitted means "always a candidate". */
34
+ type?: string;
35
+ srcset: string;
36
+ }
37
+ export interface WarmOptions {
38
+ /** Ordered, first supported wins, exactly as `<source>` children behave. */
39
+ sources?: WarmSource[];
40
+ srcset?: string;
41
+ /** A single URL, for an image with no variants. */
42
+ src?: string;
43
+ /** Handed to the browser verbatim. Nothing in this package parses it. */
44
+ sizes?: string;
45
+ }
46
+ /** Warms one image: selects the variant this browser would request, and fetches it. */
47
+ export declare function warm(options: WarmOptions): void;
48
+ /**
49
+ * Warms whatever `resolve` names when the visitor shows intent toward `selector`.
50
+ *
51
+ * Delegated on the document, so links added later are covered without rebinding
52
+ * and one listener serves a whole grid. `resolve` returns the candidates rather
53
+ * than the library inventing a `data-*` vocabulary for them: where an app keeps
54
+ * its srcsets is the app's business.
55
+ *
56
+ * Returns a teardown. Listeners on `document` survive a `<ClientRouter />` swap,
57
+ * so a caller that re-binds per navigation stacks duplicates without one.
58
+ */
59
+ export declare function warmOnIntent(selector: string, resolve: (element: Element) => WarmOptions | null | undefined): () => void;
60
+ /** Drops the dedup record so a test can warm the same thing twice. Not public API. */
61
+ export declare function resetWarmed(): void;
62
+ //# sourceMappingURL=warming.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"warming.d.ts","sourceRoot":"","sources":["../src/warming.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAIH,6EAA6E;AAC7E,MAAM,WAAW,UAAU;IACzB,4EAA4E;IAC5E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,WAAW;IAC1B,4EAA4E;IAC5E,OAAO,CAAC,EAAE,UAAU,EAAE,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,mDAAmD;IACnD,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,yEAAyE;IACzE,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AA4BD,uFAAuF;AACvF,wBAAgB,IAAI,CAAC,OAAO,EAAE,WAAW,GAAG,IAAI,CAyC/C;AASD;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAC1B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,WAAW,GAAG,IAAI,GAAG,SAAS,GAC5D,MAAM,IAAI,CAcZ;AAED,sFAAsF;AACtF,wBAAgB,WAAW,IAAI,IAAI,CAGlC"}
@@ -0,0 +1,136 @@
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
+ import { connectionAllowsMedia } from './env.js';
32
+ const warmed = new Set();
33
+ /**
34
+ * Images with a fetch still in flight.
35
+ *
36
+ * A detached element is reachable from nothing once this function returns, and a
37
+ * collected image would be an aborted fetch. Precautionary rather than a fix for
38
+ * something observed: no engine was seen dropping one. It is cheap, and the bug
39
+ * it forecloses would be silent and load dependent, which is the kind no test
40
+ * here would catch.
41
+ */
42
+ const inFlight = new Set();
43
+ /**
44
+ * Deduped on what the caller asked for rather than on the URL the browser picks,
45
+ * because the URL is not known until selection has already happened, and the
46
+ * repeat this exists to stop is the same link hovered twice.
47
+ */
48
+ function keyOf(options) {
49
+ const sources = (options.sources ?? []).map((source) => `${source.type ?? ''}|${source.srcset}`);
50
+ // Joined on NUL rather than a newline: a srcset spanning several lines is
51
+ // ordinary formatting, and would otherwise let two different candidate sets
52
+ // build the same key and silently drop the second warm.
53
+ return [...sources, options.srcset ?? '', options.src ?? '', options.sizes ?? ''].join('\0');
54
+ }
55
+ /** Warms one image: selects the variant this browser would request, and fetches it. */
56
+ export function warm(options) {
57
+ // Speculative bytes are the first thing to drop on a metered connection. Astro
58
+ // downgrades to its `tap` strategy here rather than skipping; for an image
59
+ // nobody has asked for yet, not spending them at all is the better trade.
60
+ if (!connectionAllowsMedia())
61
+ return;
62
+ const sources = options.sources ?? [];
63
+ // Checked before anything is built, so an empty call allocates nothing, and
64
+ // before `warmed` is touched, so it cannot make a later correct call a no-op.
65
+ if (sources.length === 0 && !options.srcset && !options.src) {
66
+ console.warn('polite-media: warm() was given no src, srcset or sources, so nothing was warmed.');
67
+ return;
68
+ }
69
+ const key = keyOf(options);
70
+ if (warmed.has(key))
71
+ return;
72
+ const picture = document.createElement('picture');
73
+ for (const source of sources) {
74
+ const element = document.createElement('source');
75
+ if (source.type)
76
+ element.type = source.type;
77
+ element.srcset = source.srcset;
78
+ picture.append(element);
79
+ }
80
+ const img = document.createElement('img');
81
+ picture.append(img);
82
+ // The visitor has not asked for this image and may never ask. It must not
83
+ // compete with the page they are actually looking at.
84
+ img.fetchPriority = 'low';
85
+ if (options.sizes)
86
+ img.sizes = options.sizes;
87
+ if (options.srcset)
88
+ img.srcset = options.srcset;
89
+ if (options.src)
90
+ img.src = options.src;
91
+ warmed.add(key);
92
+ inFlight.add(img);
93
+ const settled = () => void inFlight.delete(img);
94
+ img.addEventListener('load', settled, { once: true });
95
+ img.addEventListener('error', settled, { once: true });
96
+ }
97
+ /**
98
+ * The events that mean "about to navigate". `pointerover` and `focusin` cover
99
+ * Astro's documented `hover` strategy, "when you hover over or focus on the
100
+ * link"; `touchstart` covers the touch case, where a hover never happens.
101
+ */
102
+ const INTENT_EVENTS = ['pointerover', 'focusin', 'touchstart'];
103
+ /**
104
+ * Warms whatever `resolve` names when the visitor shows intent toward `selector`.
105
+ *
106
+ * Delegated on the document, so links added later are covered without rebinding
107
+ * and one listener serves a whole grid. `resolve` returns the candidates rather
108
+ * than the library inventing a `data-*` vocabulary for them: where an app keeps
109
+ * its srcsets is the app's business.
110
+ *
111
+ * Returns a teardown. Listeners on `document` survive a `<ClientRouter />` swap,
112
+ * so a caller that re-binds per navigation stacks duplicates without one.
113
+ */
114
+ export function warmOnIntent(selector, resolve) {
115
+ const onIntent = (event) => {
116
+ const element = event.target?.closest?.(selector);
117
+ if (!element)
118
+ return;
119
+ const options = resolve(element);
120
+ if (options)
121
+ warm(options);
122
+ };
123
+ for (const type of INTENT_EVENTS) {
124
+ document.addEventListener(type, onIntent, { passive: true });
125
+ }
126
+ return () => {
127
+ for (const type of INTENT_EVENTS)
128
+ document.removeEventListener(type, onIntent);
129
+ };
130
+ }
131
+ /** Drops the dedup record so a test can warm the same thing twice. Not public API. */
132
+ export function resetWarmed() {
133
+ warmed.clear();
134
+ inFlight.clear();
135
+ }
136
+ //# sourceMappingURL=warming.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"warming.js","sourceRoot":"","sources":["../src/warming.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,96 @@
1
+ {
2
+ "name": "polite-media",
3
+ "version": "0.3.0",
4
+ "description": "Background video, image reveals and next-page image warming. Reveals on the frame that actually painted, plays only what is on screen, respects data and motion.",
5
+ "keywords": [
6
+ "video",
7
+ "background-video",
8
+ "image",
9
+ "lazy",
10
+ "poster",
11
+ "autoplay",
12
+ "intersection-observer",
13
+ "requestVideoFrameCallback",
14
+ "performance",
15
+ "lcp",
16
+ "prefetch",
17
+ "srcset"
18
+ ],
19
+ "license": "MIT",
20
+ "author": "sixra",
21
+ "packageManager": "pnpm@11.25.0",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/sixra/polite-media.git"
25
+ },
26
+ "homepage": "https://github.com/sixra/polite-media#readme",
27
+ "bugs": "https://github.com/sixra/polite-media/issues",
28
+ "type": "module",
29
+ "sideEffects": [
30
+ "*.css"
31
+ ],
32
+ "exports": {
33
+ "./video": {
34
+ "types": "./dist/video.d.ts",
35
+ "default": "./dist/video.js"
36
+ },
37
+ "./image": {
38
+ "types": "./dist/image.d.ts",
39
+ "default": "./dist/image.js"
40
+ },
41
+ "./warm": {
42
+ "types": "./dist/warm.d.ts",
43
+ "default": "./dist/warm.js"
44
+ },
45
+ "./video.css": "./dist/video.css",
46
+ "./image.css": "./dist/image.css",
47
+ "./layer.css": "./dist/layer.css",
48
+ "./package.json": "./package.json"
49
+ },
50
+ "files": [
51
+ "dist",
52
+ "src",
53
+ "CHANGELOG.md"
54
+ ],
55
+ "devEngines": {
56
+ "runtime": {
57
+ "name": "node",
58
+ "version": ">=24",
59
+ "onFail": "error"
60
+ }
61
+ },
62
+ "scripts": {
63
+ "build": "node scripts/clean.js && tsc -p tsconfig.build.json && esbuild src/video.css src/image.css src/layer.css --minify --outdir=dist",
64
+ "clean": "node scripts/clean.js",
65
+ "fixtures": "sh scripts/make-fixtures.sh",
66
+ "format": "prettier --write .",
67
+ "format:check": "prettier --check .",
68
+ "lint": "eslint .",
69
+ "lint:fix": "eslint . --fix",
70
+ "prepack": "pnpm run build",
71
+ "prepare": "lefthook install",
72
+ "size": "node scripts/size-check.js",
73
+ "test": "vitest run",
74
+ "test:e2e": "pnpm run build && playwright test",
75
+ "test:watch": "vitest",
76
+ "typecheck": "tsc --noEmit",
77
+ "verify": "pnpm lint && pnpm format:check && pnpm typecheck && pnpm test && pnpm build && pnpm size"
78
+ },
79
+ "devDependencies": {
80
+ "@axe-core/playwright": "^4.13.0",
81
+ "@commitlint/cli": "^21.2.2",
82
+ "@commitlint/config-conventional": "^21.2.2",
83
+ "@eslint/js": "^10.0.1",
84
+ "@playwright/test": "^1.62.1",
85
+ "@types/node": "^26.4.0",
86
+ "esbuild": "^0.28.2",
87
+ "eslint": "^10.9.1",
88
+ "globals": "^17.11.0",
89
+ "happy-dom": "^20.12.0",
90
+ "lefthook": "^2.1.12",
91
+ "prettier": "^3.9.6",
92
+ "typescript": "^6.0.3",
93
+ "typescript-eslint": "^8.68.0",
94
+ "vitest": "^4.1.11"
95
+ }
96
+ }