@schalkneethling/css-media-pseudo-polyfill 1.0.2
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/LICENSE +21 -0
- package/README.md +121 -0
- package/dist/fn.d.mts +4 -0
- package/dist/fn.mjs +2 -0
- package/dist/index.d.mts +1 -0
- package/dist/index.mjs +6 -0
- package/dist/polyfill-BA69lY_2.mjs +503 -0
- package/package.json +59 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Schalk Neethling
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# css-media-pseudo-polyfill
|
|
2
|
+
|
|
3
|
+
A CSS polyfill for the [media pseudo-classes](https://html.spec.whatwg.org/multipage/semantics-other.html#selector-muted): `:playing`, `:paused`, `:seeking`, `:buffering`, `:stalled`, and `:muted`.
|
|
4
|
+
|
|
5
|
+
These pseudo-classes allow styling based on the playback, loading, and sound state of `<audio>` and `<video>` elements. Browser support is still incomplete — this polyfill detects which pseudo-classes the browser does not support and provides equivalent behavior via class selectors.
|
|
6
|
+
|
|
7
|
+
## How it works
|
|
8
|
+
|
|
9
|
+
The polyfill runs in four stages:
|
|
10
|
+
|
|
11
|
+
1. **Feature detection** — Each of the 7 media pseudo-classes is tested individually via `CSS.supports('selector(:name)')`. Only unsupported pseudo-classes are polyfilled. This allows partial support (e.g., Safari may support `:playing` but not `:buffering`).
|
|
12
|
+
|
|
13
|
+
2. **CSS rewriting** — Inline `<style>` elements are parsed with [css-tree](https://github.com/nicolo-ribaudo/css-tree). For each rule containing a target pseudo-class, a class-based equivalent is injected as a sibling rule immediately after the original in the AST (e.g., `video:playing { ... }` is followed by `video.media-pseudo-polyfill-playing { ... }`). The browser skips the rule it doesn't understand and applies the class-based fallback — a natural progressive enhancement pair.
|
|
14
|
+
|
|
15
|
+
3. **`<link>` stylesheet rewriting** — Same-origin `<link rel="stylesheet">` elements are fetched via the Fetch API and rewritten using the same text-based approach as inline `<style>` elements. The rewritten CSS is injected as a sibling `<style>` element and the original `<link>` is disabled. This avoids the CSSOM approach where browsers silently drop rules containing unrecognised pseudo-class selectors before the polyfill can process them. Cross-origin stylesheets are detected via origin comparison and skipped.
|
|
16
|
+
|
|
17
|
+
4. **Element observation** — Media elements are discovered via `querySelectorAll` and a `MutationObserver`. Event listeners are attached to each element to track state changes. On every relevant event, the element's state is recomputed and the corresponding polyfill classes are toggled.
|
|
18
|
+
|
|
19
|
+
## Entry points
|
|
20
|
+
|
|
21
|
+
| Export | Description |
|
|
22
|
+
| --------------- | ------------------------------------------------------- |
|
|
23
|
+
| `"."` (default) | Auto-applies the polyfill on `DOMContentLoaded` |
|
|
24
|
+
| `"./fn"` | Exports the `polyfill()` function for manual invocation |
|
|
25
|
+
|
|
26
|
+
The `"./fn"` entry point is useful when you need to run the polyfill earlier (e.g., from a synchronous `<script>` in `<head>`) to minimize the flash of unstyled content (FOUC).
|
|
27
|
+
|
|
28
|
+
## Spec references
|
|
29
|
+
|
|
30
|
+
The pseudo-class definitions and their DOM conditions come from the [WHATWG HTML spec](https://html.spec.whatwg.org/multipage/semantics-other.html#selector-muted):
|
|
31
|
+
|
|
32
|
+
| Pseudo-class | DOM condition |
|
|
33
|
+
| ---------------- | -------------------------------------------------------------------------------- |
|
|
34
|
+
| `:playing` | `paused === false` |
|
|
35
|
+
| `:paused` | `paused === true` |
|
|
36
|
+
| `:seeking` | `seeking === true` |
|
|
37
|
+
| `:buffering` | `!paused && networkState === NETWORK_LOADING && readyState <= HAVE_CURRENT_DATA` |
|
|
38
|
+
| `:stalled` | matches `:buffering` AND the internal "is currently stalled" flag is `true` |
|
|
39
|
+
| `:muted` | `muted === true` |
|
|
40
|
+
| `:volume-locked` | Not polyfillable (no DOM surface) |
|
|
41
|
+
|
|
42
|
+
The `NETWORK_LOADING`, `HAVE_CURRENT_DATA`, and other constants are defined on the [HTMLMediaElement interface](https://html.spec.whatwg.org/multipage/media.html#htmlmediaelement) as `const unsigned short` values. They are available as both static and instance properties.
|
|
43
|
+
|
|
44
|
+
## Design decisions
|
|
45
|
+
|
|
46
|
+
### Per-pseudo-class detection
|
|
47
|
+
|
|
48
|
+
Rather than a single feature check, each pseudo-class is tested individually. Safari has partial support — it may implement some pseudo-classes but not others. Per-pseudo-class detection allows the polyfill to skip already-supported pseudo-classes and only rewrite and manage the unsupported ones. If `CSS.supports` is unavailable or throws, the pseudo-class is treated as unsupported.
|
|
49
|
+
|
|
50
|
+
### Immediate-sibling injection for cascade preservation
|
|
51
|
+
|
|
52
|
+
For each rule containing a target pseudo-class, the polyfill inserts a class-based equivalent as a sibling rule immediately after the original in the AST. The original stylesheet is left untouched. This produces pairs like:
|
|
53
|
+
|
|
54
|
+
```css
|
|
55
|
+
video:playing {
|
|
56
|
+
outline: 0.25rem solid green;
|
|
57
|
+
}
|
|
58
|
+
video.media-pseudo-polyfill-playing {
|
|
59
|
+
outline: 0.25rem solid green;
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Two alternative approaches were considered and rejected:
|
|
64
|
+
|
|
65
|
+
- **Clone-and-disable** (clone the entire AST, disable the original stylesheet): unnecessarily complex. Because the polyfill class is only present when the state is active, the injected rule is inert when the state doesn't apply — it cannot interfere with other rules regardless of source order. This is fundamentally different from attribute-based polyfills (e.g., container queries) where rewritten selectors match unconditionally.
|
|
66
|
+
|
|
67
|
+
- **Extract-and-append** (extract only rewritten rules, append to end): incorrect. Moving rules out of their `@layer`, `@media`, or `@supports` context would break the author's cascade intent.
|
|
68
|
+
|
|
69
|
+
Immediate-sibling injection keeps each rewritten rule inside the same block as its original, preserving `@layer`, `@media`, and `@supports` nesting with no special logic.
|
|
70
|
+
|
|
71
|
+
### Specificity-neutral substitution
|
|
72
|
+
|
|
73
|
+
The polyfill replaces `PseudoClassSelector` nodes (specificity 0,1,0) with `ClassSelector` nodes (also 0,1,0). This substitution is specificity-neutral in all contexts — including inside `:is()` (which uses the most specific argument's specificity), `:where()` (which zeroes everything), and `:has()` (which contributes the argument's specificity). No `:where()` wrapping is needed.
|
|
74
|
+
|
|
75
|
+
### `:volume-locked` handling
|
|
76
|
+
|
|
77
|
+
`:volume-locked` cannot be polyfilled because the "volume locked" flag is a user-agent-level boolean with no DOM surface. The polyfill handles it as follows to prevent broken stylesheets:
|
|
78
|
+
|
|
79
|
+
- **Lone selector** (`video:volume-locked { ... }`): the entire rule is removed
|
|
80
|
+
- **In a selector list** (`video:playing, video:volume-locked { ... }`): the `:volume-locked` branch is pruned; the `:playing` branch is rewritten normally
|
|
81
|
+
- **Inside `:is()` / `:where()`**: the `:volume-locked` argument is removed from the list
|
|
82
|
+
- **Inside `:not()`**: rewritten to `.media-pseudo-polyfill-volume-locked` (matches everything, since the class is never set — consistent behavior)
|
|
83
|
+
|
|
84
|
+
### Pure state computation with externally managed stalled flag
|
|
85
|
+
|
|
86
|
+
The `computeStates()` function is pure — it reads properties from an `HTMLMediaElement` and returns a `Set<string>` of active states. The "is currently stalled" flag is passed in as a parameter rather than being computed internally because this flag is not directly observable from DOM properties. It must be tracked via the formal state machine defined in the [HTML spec](https://html.spec.whatwg.org/multipage/media.html#concept-media-load-resource):
|
|
87
|
+
|
|
88
|
+
- Set to `true` when the `stalled` event fires (browser's ~3 second stall timeout expired)
|
|
89
|
+
- Reset to `false` when `progress`, `emptied`, or `loadstart` fires
|
|
90
|
+
|
|
91
|
+
This separation keeps `computeStates()` easily testable with plain objects.
|
|
92
|
+
|
|
93
|
+
### WeakMap for per-element state
|
|
94
|
+
|
|
95
|
+
Per-element state (event handler reference and stalled flag) is stored in a `WeakMap<HTMLMediaElement, ElementState>`. The `MutationObserver` callback explicitly cleans up when elements are removed from the DOM. The `WeakMap` acts as a safety net — if cleanup is missed (e.g., observer disconnected, edge case in DOM reparenting), the garbage collector can still reclaim the element and its associated state. A regular `Map` would hold a strong reference to the element key, preventing collection. On pages with many dynamic video elements (e.g., YouTube feed with preview hovers), this prevents memory leaks.
|
|
96
|
+
|
|
97
|
+
### Single event handler per element
|
|
98
|
+
|
|
99
|
+
Media events do not bubble, so each element requires direct `addEventListener` calls. Rather than creating 14 separate handler functions per element, a single handler is created and registered for all 14 event types. The handler uses `event.type` in a switch statement to handle special cases (stalled flag transitions) before recomputing state. This means 14 registrations per element (unavoidable) but only 1 function object allocated per element — on a page with 20 videos, that is 20 function objects instead of 280.
|
|
100
|
+
|
|
101
|
+
### Bind-once guard
|
|
102
|
+
|
|
103
|
+
Before attaching listeners, the polyfill checks whether the element is already tracked in the `WeakMap`. This prevents duplicate bindings from rapid DOM reparenting, where the `MutationObserver` may report the same element in both `removedNodes` and `addedNodes` in a single batch.
|
|
104
|
+
|
|
105
|
+
### Fetch-based rewriting for `<link>` stylesheets
|
|
106
|
+
|
|
107
|
+
Same-origin `<link rel="stylesheet">` elements are rewritten by fetching the CSS text and running it through the same `rewriteCss()` function used for inline `<style>` elements. The rewritten output is injected as a `<style>` element immediately after the original `<link>`, which is then disabled.
|
|
108
|
+
|
|
109
|
+
An earlier approach used the CSSOM API (`sheet.cssRules` + `insertRule()`), which avoids an extra network request. However, this has a fundamental flaw: browsers silently drop rules containing unrecognised pseudo-class selectors during CSS parsing. By the time the polyfill walks the CSSOM, the rules it needs to rewrite have already been discarded. The fetch-based approach accesses the raw CSS text before browser parsing, ensuring all rules are available for rewriting.
|
|
110
|
+
|
|
111
|
+
Cross-origin stylesheets are detected via `new URL(href).origin` comparison against `window.location.origin` and skipped. This covers both relative paths (resolved to absolute by the browser) and explicit absolute URLs to external CDNs.
|
|
112
|
+
|
|
113
|
+
## Known limitations
|
|
114
|
+
|
|
115
|
+
- **`:volume-locked` is not polyfillable.** The "volume locked" flag has no DOM surface. The polyfill removes `:volume-locked` selectors from rewritten stylesheets and never sets the corresponding class.
|
|
116
|
+
|
|
117
|
+
- **Class selectors can match non-media elements.** Native `:playing` only matches `<audio>` and `<video>` elements. The polyfilled `.media-pseudo-polyfill-playing` class has no such restriction. In practice this is not an issue because the polyfill only ever toggles these classes on media elements. An author would need to manually add the class to a non-media element to trigger a false positive.
|
|
118
|
+
|
|
119
|
+
- **FOUC window.** Stylesheet rewriting runs when the polyfill is invoked (at `DOMContentLoaded` for the default entry point). There is a window between first paint and polyfill initialization where pseudo-class-based styles are not applied. Use the `"./fn"` entry point from a synchronous `<script>` in `<head>` to minimize this gap.
|
|
120
|
+
|
|
121
|
+
- **Cross-origin `<link>` stylesheets are not rewritten.** The polyfill only fetches and rewrites same-origin stylesheets. Cross-origin sheets served from external CDNs are skipped.
|
package/dist/fn.d.mts
ADDED
package/dist/fn.mjs
ADDED
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
import { clone, generate, parse, walk } from "css-tree";
|
|
2
|
+
//#region src/constants.ts
|
|
3
|
+
const PSEUDO_CLASSES = [
|
|
4
|
+
"playing",
|
|
5
|
+
"paused",
|
|
6
|
+
"seeking",
|
|
7
|
+
"buffering",
|
|
8
|
+
"stalled",
|
|
9
|
+
"muted",
|
|
10
|
+
"volume-locked"
|
|
11
|
+
];
|
|
12
|
+
const CLASS_PREFIX = "media-pseudo-polyfill-";
|
|
13
|
+
const MEDIA_EVENTS = [
|
|
14
|
+
"play",
|
|
15
|
+
"playing",
|
|
16
|
+
"pause",
|
|
17
|
+
"ended",
|
|
18
|
+
"seeking",
|
|
19
|
+
"seeked",
|
|
20
|
+
"waiting",
|
|
21
|
+
"canplay",
|
|
22
|
+
"canplaythrough",
|
|
23
|
+
"stalled",
|
|
24
|
+
"progress",
|
|
25
|
+
"volumechange",
|
|
26
|
+
"emptied",
|
|
27
|
+
"loadstart"
|
|
28
|
+
];
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/rewrite.ts
|
|
31
|
+
/**
|
|
32
|
+
* Rewrite CSS text using immediate-sibling injection. For each rule containing
|
|
33
|
+
* an unsupported media pseudo-class selector, a class-based equivalent is
|
|
34
|
+
* inserted as a sibling rule immediately after the original. The original rules
|
|
35
|
+
* are preserved — the browser silently skips rules it does not understand and
|
|
36
|
+
* applies the class-based fallback. Returns null if no rewrites occurred.
|
|
37
|
+
*/
|
|
38
|
+
function rewriteCss(cssText, unsupported) {
|
|
39
|
+
const ast = parse(cssText);
|
|
40
|
+
let rewrote = false;
|
|
41
|
+
injectSiblingRules(ast, unsupported, (didRewrite) => {
|
|
42
|
+
if (didRewrite) rewrote = true;
|
|
43
|
+
});
|
|
44
|
+
if (!rewrote) return null;
|
|
45
|
+
const output = generate(ast);
|
|
46
|
+
if (!output.trim()) return null;
|
|
47
|
+
return output;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Walk the AST looking for Rule nodes that contain target pseudo-classes.
|
|
51
|
+
* For each match, clone the rule, rewrite selectors in the clone, and
|
|
52
|
+
* insert the clone immediately after the original in the parent list.
|
|
53
|
+
*/
|
|
54
|
+
function injectSiblingRules(ast, unsupported, onRewrite) {
|
|
55
|
+
const rulesToProcess = [];
|
|
56
|
+
walk(ast, {
|
|
57
|
+
visit: "Rule",
|
|
58
|
+
enter(node, item, list) {
|
|
59
|
+
if (containsTargetPseudoClass(node, unsupported)) rulesToProcess.push({
|
|
60
|
+
rule: node,
|
|
61
|
+
item,
|
|
62
|
+
list
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
for (const { rule, item, list } of rulesToProcess) {
|
|
67
|
+
const cloned = clone(rule);
|
|
68
|
+
const prelude = cloned.prelude;
|
|
69
|
+
if (prelude.type !== "SelectorList") continue;
|
|
70
|
+
if (!rewriteSelectorList(prelude, unsupported)) continue;
|
|
71
|
+
if (prelude.children.isEmpty) continue;
|
|
72
|
+
const newItem = list.createItem(cloned);
|
|
73
|
+
if (item.next) list.insert(newItem, item.next);
|
|
74
|
+
else list.appendData(cloned);
|
|
75
|
+
onRewrite(true);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Check whether a CSS node contains any target pseudo-class selectors.
|
|
80
|
+
*/
|
|
81
|
+
function containsTargetPseudoClass(node, unsupported) {
|
|
82
|
+
let found = false;
|
|
83
|
+
walk(node, {
|
|
84
|
+
visit: "PseudoClassSelector",
|
|
85
|
+
enter(pseudoNode) {
|
|
86
|
+
if (unsupported.has(pseudoNode.name)) {
|
|
87
|
+
found = true;
|
|
88
|
+
return this.break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
return found;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Rewrite pseudo-class selectors to class selectors in a SelectorList node,
|
|
96
|
+
* mutating it in place. Handles :volume-locked pruning and :not() rewriting.
|
|
97
|
+
* Returns whether any rewrites occurred.
|
|
98
|
+
*
|
|
99
|
+
* Used by both the <style> text rewriting path and the <link> CSSOM path.
|
|
100
|
+
*/
|
|
101
|
+
function rewriteSelectorList(selectorList, unsupported) {
|
|
102
|
+
let rewrote = false;
|
|
103
|
+
walk(selectorList, {
|
|
104
|
+
visit: "PseudoClassSelector",
|
|
105
|
+
enter(node, pseudoItem, pseudoList) {
|
|
106
|
+
if (!unsupported.has(node.name)) return;
|
|
107
|
+
if (node.name === "volume-locked") return;
|
|
108
|
+
rewrote = true;
|
|
109
|
+
const replacement = pseudoList.createItem({
|
|
110
|
+
type: "ClassSelector",
|
|
111
|
+
name: `${CLASS_PREFIX}${node.name}`,
|
|
112
|
+
loc: node.loc
|
|
113
|
+
});
|
|
114
|
+
pseudoList.replace(pseudoItem, replacement);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
if (unsupported.has("volume-locked")) {
|
|
118
|
+
if (handleVolumeLocked(selectorList)) rewrote = true;
|
|
119
|
+
}
|
|
120
|
+
return rewrote;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Handle :volume-locked pseudo-class in a selector list. Although unpolyfillable,
|
|
124
|
+
* it cannot be left as-is in the sibling rule: in a comma-separated selector
|
|
125
|
+
* list, one invalid selector causes the browser to discard the entire rule —
|
|
126
|
+
* breaking sibling selectors that were successfully rewritten.
|
|
127
|
+
*
|
|
128
|
+
* - In a selector list → prune the :volume-locked branch
|
|
129
|
+
* - Lone selector → list becomes empty (caller skips injection)
|
|
130
|
+
* - Inside :is()/:where() → prune from argument list
|
|
131
|
+
* - Inside :not() → rewrite to class selector (matches everything, consistent)
|
|
132
|
+
*/
|
|
133
|
+
function handleVolumeLocked(selectorList) {
|
|
134
|
+
let rewrote = false;
|
|
135
|
+
walk(selectorList, {
|
|
136
|
+
visit: "PseudoClassSelector",
|
|
137
|
+
enter(node) {
|
|
138
|
+
if (node.name !== "not" || !node.children) return;
|
|
139
|
+
walk(node, {
|
|
140
|
+
visit: "PseudoClassSelector",
|
|
141
|
+
enter(inner, innerItem, innerList) {
|
|
142
|
+
if (inner.name !== "volume-locked") return;
|
|
143
|
+
rewrote = true;
|
|
144
|
+
const replacement = innerList.createItem({
|
|
145
|
+
type: "ClassSelector",
|
|
146
|
+
name: `${CLASS_PREFIX}volume-locked`,
|
|
147
|
+
loc: inner.loc
|
|
148
|
+
});
|
|
149
|
+
innerList.replace(innerItem, replacement);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
walk(selectorList, {
|
|
155
|
+
visit: "PseudoClassSelector",
|
|
156
|
+
enter(node) {
|
|
157
|
+
if (node.name !== "is" && node.name !== "where") return;
|
|
158
|
+
if (!node.children) return;
|
|
159
|
+
const nestedSelectorList = node.children.first;
|
|
160
|
+
if (nestedSelectorList?.type === "SelectorList") {
|
|
161
|
+
if (pruneSelectorsWithVolumeLocked(nestedSelectorList)) rewrote = true;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
if (pruneSelectorsWithVolumeLocked(selectorList)) rewrote = true;
|
|
166
|
+
return rewrote;
|
|
167
|
+
}
|
|
168
|
+
function containsVolumeLocked(node) {
|
|
169
|
+
let found = false;
|
|
170
|
+
walk(node, {
|
|
171
|
+
visit: "PseudoClassSelector",
|
|
172
|
+
enter(node) {
|
|
173
|
+
if (node.name === "volume-locked") {
|
|
174
|
+
found = true;
|
|
175
|
+
return this.break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
return found;
|
|
180
|
+
}
|
|
181
|
+
function pruneSelectorsWithVolumeLocked(selectorList) {
|
|
182
|
+
const toRemove = [];
|
|
183
|
+
selectorList.children.forEach((selector, selectorItem) => {
|
|
184
|
+
if (containsVolumeLocked(selector)) toRemove.push(selectorItem);
|
|
185
|
+
});
|
|
186
|
+
for (const selectorItem of toRemove) selectorList.children.remove(selectorItem);
|
|
187
|
+
return toRemove.length > 0;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Process a single inline <style> element.
|
|
191
|
+
* If it contains target pseudo-classes, replaces its content
|
|
192
|
+
* with the rewritten CSS (originals plus class-based siblings).
|
|
193
|
+
*/
|
|
194
|
+
function rewriteSingleStyleElement(style, unsupported) {
|
|
195
|
+
const cssText = style.textContent;
|
|
196
|
+
if (!cssText) return;
|
|
197
|
+
const rewritten = rewriteCss(cssText, unsupported);
|
|
198
|
+
if (rewritten === null) return;
|
|
199
|
+
style.textContent = rewritten;
|
|
200
|
+
style.setAttribute("data-polyfill-rewritten", "");
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Process all inline <style> elements in the document.
|
|
204
|
+
* For each that contains target pseudo-classes, replaces its content
|
|
205
|
+
* with the rewritten CSS (originals plus class-based siblings).
|
|
206
|
+
*/
|
|
207
|
+
function rewriteStyleElements(unsupported) {
|
|
208
|
+
const styles = document.querySelectorAll("style:not([data-polyfill-rewritten])");
|
|
209
|
+
for (const style of styles) rewriteSingleStyleElement(style, unsupported);
|
|
210
|
+
}
|
|
211
|
+
//#endregion
|
|
212
|
+
//#region src/rewrite-link.ts
|
|
213
|
+
/**
|
|
214
|
+
* Check whether a stylesheet URL is same-origin. The browser resolves
|
|
215
|
+
* relative hrefs to absolute URLs on the HTMLLinkElement.href property,
|
|
216
|
+
* so comparing the origin portion covers both relative paths and
|
|
217
|
+
* absolute same-origin URLs while filtering out external CDN links.
|
|
218
|
+
*/
|
|
219
|
+
function isSameOrigin(href) {
|
|
220
|
+
try {
|
|
221
|
+
return new URL(href).origin === window.location.origin;
|
|
222
|
+
} catch {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Fetch CSS text from a linked stylesheet, rewrite unsupported media
|
|
228
|
+
* pseudo-classes to class-based selectors using the same text-based
|
|
229
|
+
* sibling-injection strategy as inline <style> elements, then inject
|
|
230
|
+
* the rewritten CSS as a <style> element and disable the original link.
|
|
231
|
+
*
|
|
232
|
+
* This avoids the CSSOM approach where the browser silently drops rules
|
|
233
|
+
* with unrecognised pseudo-class selectors before the polyfill can
|
|
234
|
+
* process them.
|
|
235
|
+
*/
|
|
236
|
+
async function processLinkSheet(link, unsupported) {
|
|
237
|
+
if (link.hasAttribute("data-polyfill-rewritten")) return;
|
|
238
|
+
const href = link.href;
|
|
239
|
+
if (!href || !isSameOrigin(href)) return;
|
|
240
|
+
let cssText;
|
|
241
|
+
try {
|
|
242
|
+
cssText = await (await fetch(href)).text();
|
|
243
|
+
} catch {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const rewritten = rewriteCss(cssText, unsupported);
|
|
247
|
+
link.setAttribute("data-polyfill-rewritten", "");
|
|
248
|
+
if (rewritten === null) return;
|
|
249
|
+
const style = document.createElement("style");
|
|
250
|
+
style.textContent = rewritten;
|
|
251
|
+
style.setAttribute("data-polyfill-rewritten", "");
|
|
252
|
+
link.after(style);
|
|
253
|
+
link.disabled = true;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Process all <link rel="stylesheet"> elements in the document.
|
|
257
|
+
* For each same-origin link, fetches the CSS text, rewrites it, and
|
|
258
|
+
* injects the result as a sibling <style> element.
|
|
259
|
+
*
|
|
260
|
+
* @param unsupported - The set of pseudo-class names that need polyfilling.
|
|
261
|
+
*/
|
|
262
|
+
function rewriteLinkStylesheets(unsupported) {
|
|
263
|
+
const links = document.querySelectorAll("link[rel=\"stylesheet\"]:not([data-polyfill-rewritten])");
|
|
264
|
+
for (const link of links) processLinkSheet(link, unsupported);
|
|
265
|
+
}
|
|
266
|
+
//#endregion
|
|
267
|
+
//#region src/state.ts
|
|
268
|
+
/**
|
|
269
|
+
* Computes the current set of pseudo-class states for a media element
|
|
270
|
+
* based on its playback, network, and user-interaction properties.
|
|
271
|
+
*
|
|
272
|
+
* @param element - The media element to inspect.
|
|
273
|
+
* @param isStalledFlag - Whether a `stalled` event has fired since the last
|
|
274
|
+
* `progress` event, indicating the browser has not received new data.
|
|
275
|
+
* @returns A set of pseudo-class name strings (e.g. "paused", "playing",
|
|
276
|
+
* "buffering", "stalled", "seeking", "muted") derived from the element's
|
|
277
|
+
* properties at the time of the call.
|
|
278
|
+
*/
|
|
279
|
+
function computeStates(element, isStalledFlag) {
|
|
280
|
+
const states = /* @__PURE__ */ new Set();
|
|
281
|
+
if (element.paused) states.add("paused");
|
|
282
|
+
else {
|
|
283
|
+
states.add("playing");
|
|
284
|
+
if (element.networkState === element.NETWORK_LOADING && element.readyState <= element.HAVE_CURRENT_DATA) {
|
|
285
|
+
states.add("buffering");
|
|
286
|
+
if (isStalledFlag) states.add("stalled");
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (element.seeking) states.add("seeking");
|
|
290
|
+
if (element.muted) states.add("muted");
|
|
291
|
+
return states;
|
|
292
|
+
}
|
|
293
|
+
//#endregion
|
|
294
|
+
//#region src/observe.ts
|
|
295
|
+
const elementStates = /* @__PURE__ */ new WeakMap();
|
|
296
|
+
function isMediaElement(node) {
|
|
297
|
+
const tagName = node.tagName;
|
|
298
|
+
return tagName === "VIDEO" || tagName === "AUDIO";
|
|
299
|
+
}
|
|
300
|
+
function computeAndApply(element, unsupported) {
|
|
301
|
+
const state = elementStates.get(element);
|
|
302
|
+
if (!state) return;
|
|
303
|
+
const activeStates = computeStates(element, state.isCurrentlyStalled);
|
|
304
|
+
for (const name of PSEUDO_CLASSES) {
|
|
305
|
+
if (!unsupported.has(name)) continue;
|
|
306
|
+
const className = `${CLASS_PREFIX}${name}`;
|
|
307
|
+
element.classList.toggle(className, activeStates.has(name));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
function attachListeners(element, unsupported) {
|
|
311
|
+
if (elementStates.has(element)) return;
|
|
312
|
+
const state = {
|
|
313
|
+
handler: () => {},
|
|
314
|
+
isCurrentlyStalled: false
|
|
315
|
+
};
|
|
316
|
+
const handler = (event) => {
|
|
317
|
+
switch (event.type) {
|
|
318
|
+
case "stalled":
|
|
319
|
+
state.isCurrentlyStalled = true;
|
|
320
|
+
break;
|
|
321
|
+
case "progress":
|
|
322
|
+
case "emptied":
|
|
323
|
+
case "loadstart":
|
|
324
|
+
state.isCurrentlyStalled = false;
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
computeAndApply(element, unsupported);
|
|
328
|
+
};
|
|
329
|
+
state.handler = handler;
|
|
330
|
+
elementStates.set(element, state);
|
|
331
|
+
for (const eventType of MEDIA_EVENTS) element.addEventListener(eventType, handler);
|
|
332
|
+
computeAndApply(element, unsupported);
|
|
333
|
+
}
|
|
334
|
+
function detachListeners(element) {
|
|
335
|
+
const state = elementStates.get(element);
|
|
336
|
+
if (!state) return;
|
|
337
|
+
for (const eventType of MEDIA_EVENTS) element.removeEventListener(eventType, state.handler);
|
|
338
|
+
elementStates.delete(element);
|
|
339
|
+
}
|
|
340
|
+
function discoverMediaElements(node, unsupported) {
|
|
341
|
+
if (isMediaElement(node)) attachListeners(node, unsupported);
|
|
342
|
+
if (node.nodeType === 1) {
|
|
343
|
+
const mediaElements = node.querySelectorAll("audio, video");
|
|
344
|
+
for (const mediaElement of mediaElements) attachListeners(mediaElement, unsupported);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
function cleanupMediaElements(node) {
|
|
348
|
+
if (isMediaElement(node)) detachListeners(node);
|
|
349
|
+
if (node.nodeType === 1) {
|
|
350
|
+
const mediaElements = node.querySelectorAll("audio, video");
|
|
351
|
+
for (const mediaElement of mediaElements) detachListeners(mediaElement);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Discovers media elements in the DOM, attaches event listeners, and
|
|
356
|
+
* toggles polyfill classes based on computed state. Sets up a
|
|
357
|
+
* MutationObserver to handle dynamically added and removed elements.
|
|
358
|
+
*
|
|
359
|
+
* @param unsupported - The set of pseudo-class names that need polyfilling.
|
|
360
|
+
*/
|
|
361
|
+
function observeMediaElements(unsupported) {
|
|
362
|
+
const existingElements = document.querySelectorAll("audio, video");
|
|
363
|
+
for (const element of existingElements) attachListeners(element, unsupported);
|
|
364
|
+
new MutationObserver((mutations) => {
|
|
365
|
+
for (const mutation of mutations) {
|
|
366
|
+
for (const addedNode of mutation.addedNodes) discoverMediaElements(addedNode, unsupported);
|
|
367
|
+
for (const removedNode of mutation.removedNodes) cleanupMediaElements(removedNode);
|
|
368
|
+
}
|
|
369
|
+
}).observe(document.documentElement, {
|
|
370
|
+
childList: true,
|
|
371
|
+
subtree: true
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
//#endregion
|
|
375
|
+
//#region src/observe-stylesheets.ts
|
|
376
|
+
/**
|
|
377
|
+
* Tracks <style> elements currently being rewritten by the polyfill.
|
|
378
|
+
* Used to distinguish polyfill-initiated textContent changes from
|
|
379
|
+
* author-initiated ones, preventing infinite mutation loops.
|
|
380
|
+
*/
|
|
381
|
+
const rewritingInProgress = /* @__PURE__ */ new WeakSet();
|
|
382
|
+
/**
|
|
383
|
+
* Check whether a node is an Element (nodeType 1).
|
|
384
|
+
*/
|
|
385
|
+
function isElement(node) {
|
|
386
|
+
return node.nodeType === 1;
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Check whether an element is a <style> element.
|
|
390
|
+
*/
|
|
391
|
+
function isStyleElement(element) {
|
|
392
|
+
return element.tagName === "STYLE";
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Check whether an element is a <link rel="stylesheet">.
|
|
396
|
+
*/
|
|
397
|
+
function isStylesheetLink(element) {
|
|
398
|
+
return element.tagName === "LINK" && element.getAttribute("rel") === "stylesheet";
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Rewrite a <style> element, guarding against mutation loops via the
|
|
402
|
+
* rewritingInProgress WeakSet.
|
|
403
|
+
*/
|
|
404
|
+
function rewriteStyleWithGuard(style, unsupported) {
|
|
405
|
+
rewritingInProgress.add(style);
|
|
406
|
+
rewriteSingleStyleElement(style, unsupported);
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Process a single added node: if it is (or contains) stylesheet elements,
|
|
410
|
+
* rewrite them.
|
|
411
|
+
*/
|
|
412
|
+
function processAddedNode(node, unsupported) {
|
|
413
|
+
if (!isElement(node)) return;
|
|
414
|
+
if (isStyleElement(node) && !node.hasAttribute("data-polyfill-rewritten")) rewriteStyleWithGuard(node, unsupported);
|
|
415
|
+
else if (isStylesheetLink(node) && !node.hasAttribute("data-polyfill-rewritten")) processLinkElement(node, unsupported);
|
|
416
|
+
for (const style of node.querySelectorAll("style:not([data-polyfill-rewritten])")) rewriteStyleWithGuard(style, unsupported);
|
|
417
|
+
for (const link of node.querySelectorAll("link[rel=\"stylesheet\"]:not([data-polyfill-rewritten])")) processLinkElement(link, unsupported);
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Handle a childList mutation whose target is a <style> element.
|
|
421
|
+
* This fires when textContent is replaced (old Text child removed, new added).
|
|
422
|
+
*/
|
|
423
|
+
function handleStyleChildListMutation(style, unsupported) {
|
|
424
|
+
if (rewritingInProgress.has(style)) {
|
|
425
|
+
rewritingInProgress.delete(style);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
style.removeAttribute("data-polyfill-rewritten");
|
|
429
|
+
rewriteStyleWithGuard(style, unsupported);
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Handle a characterData mutation on a text node inside a <style> element.
|
|
433
|
+
* This fires when the author modifies the text node directly
|
|
434
|
+
* (e.g., style.firstChild.data = "...").
|
|
435
|
+
*/
|
|
436
|
+
function handleCharacterDataMutation(target, unsupported) {
|
|
437
|
+
const parent = target.parentElement;
|
|
438
|
+
if (!parent || !isStyleElement(parent)) return;
|
|
439
|
+
if (rewritingInProgress.has(parent)) {
|
|
440
|
+
rewritingInProgress.delete(parent);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
parent.removeAttribute("data-polyfill-rewritten");
|
|
444
|
+
rewriteStyleWithGuard(parent, unsupported);
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Process a <link rel="stylesheet"> element. processLinkSheet handles
|
|
448
|
+
* fetching the CSS text directly, so no load-event deferral is needed.
|
|
449
|
+
*/
|
|
450
|
+
function processLinkElement(link, unsupported) {
|
|
451
|
+
processLinkSheet(link, unsupported);
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Observe the DOM for dynamically added or mutated stylesheets and
|
|
455
|
+
* rewrite them to polyfill unsupported media pseudo-classes.
|
|
456
|
+
*/
|
|
457
|
+
function observeStylesheets(unsupported) {
|
|
458
|
+
new MutationObserver((records) => {
|
|
459
|
+
for (const record of records) if (record.type === "childList") {
|
|
460
|
+
const target = record.target;
|
|
461
|
+
if (isElement(target) && isStyleElement(target) && target.hasAttribute("data-polyfill-rewritten")) {
|
|
462
|
+
handleStyleChildListMutation(target, unsupported);
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
for (const node of record.addedNodes) processAddedNode(node, unsupported);
|
|
466
|
+
} else if (record.type === "characterData") handleCharacterDataMutation(record.target, unsupported);
|
|
467
|
+
else if (record.type === "attributes") {
|
|
468
|
+
const target = record.target;
|
|
469
|
+
if (isElement(target) && isStylesheetLink(target)) {
|
|
470
|
+
target.removeAttribute("data-polyfill-rewritten");
|
|
471
|
+
processLinkSheet(target, unsupported);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}).observe(document.documentElement, {
|
|
475
|
+
childList: true,
|
|
476
|
+
subtree: true,
|
|
477
|
+
characterData: true,
|
|
478
|
+
attributes: true,
|
|
479
|
+
attributeFilter: ["href"]
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
//#endregion
|
|
483
|
+
//#region src/polyfill.ts
|
|
484
|
+
function detectUnsupported() {
|
|
485
|
+
const unsupported = /* @__PURE__ */ new Set();
|
|
486
|
+
if (typeof CSS === "undefined" || typeof CSS.supports !== "function") return new Set(PSEUDO_CLASSES);
|
|
487
|
+
for (const name of PSEUDO_CLASSES) try {
|
|
488
|
+
if (!CSS.supports(`selector(:${name})`)) unsupported.add(name);
|
|
489
|
+
} catch {
|
|
490
|
+
unsupported.add(name);
|
|
491
|
+
}
|
|
492
|
+
return unsupported;
|
|
493
|
+
}
|
|
494
|
+
function polyfill() {
|
|
495
|
+
const unsupported = detectUnsupported();
|
|
496
|
+
if (unsupported.size === 0) return;
|
|
497
|
+
rewriteStyleElements(unsupported);
|
|
498
|
+
rewriteLinkStylesheets(unsupported);
|
|
499
|
+
observeMediaElements(unsupported);
|
|
500
|
+
observeStylesheets(unsupported);
|
|
501
|
+
}
|
|
502
|
+
//#endregion
|
|
503
|
+
export { polyfill as t };
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@schalkneethling/css-media-pseudo-polyfill",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "A CSS polyfill for media pseudo-classes (:playing, :paused, :seeking, :buffering, :stalled, :muted)",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"audio",
|
|
7
|
+
"buffering",
|
|
8
|
+
"css",
|
|
9
|
+
"media",
|
|
10
|
+
"muted",
|
|
11
|
+
"paused",
|
|
12
|
+
"playing",
|
|
13
|
+
"polyfill",
|
|
14
|
+
"pseudo-classes",
|
|
15
|
+
"seeking",
|
|
16
|
+
"video"
|
|
17
|
+
],
|
|
18
|
+
"homepage": "https://github.com/schalkneethling/css-media-pseudo-polyfill",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/schalkneethling/css-media-pseudo-polyfill/issues"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"author": "Schalk Neethling",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "https://github.com/schalkneethling/css-media-pseudo-polyfill"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist"
|
|
30
|
+
],
|
|
31
|
+
"type": "module",
|
|
32
|
+
"exports": {
|
|
33
|
+
".": "./dist/index.mjs",
|
|
34
|
+
"./fn": "./dist/fn.mjs",
|
|
35
|
+
"./package.json": "./package.json"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"css-tree": "^3.1.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@playwright/test": "^1.52.0",
|
|
42
|
+
"@types/css-tree": "^2.3.10",
|
|
43
|
+
"@types/node": "^25.5.0",
|
|
44
|
+
"@typescript/native-preview": "7.0.0-dev.20260316.1",
|
|
45
|
+
"bumpp": "^11.0.1",
|
|
46
|
+
"typescript": "^5.9.3",
|
|
47
|
+
"vite": "latest",
|
|
48
|
+
"vite-plus": "latest",
|
|
49
|
+
"vitest": "npm:@voidzero-dev/vite-plus-test@latest"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "vp pack",
|
|
53
|
+
"dev": "vp pack --watch",
|
|
54
|
+
"test": "vp test",
|
|
55
|
+
"test:e2e": "playwright test",
|
|
56
|
+
"typecheck": "tsc --noEmit",
|
|
57
|
+
"release": "bumpp"
|
|
58
|
+
}
|
|
59
|
+
}
|