|
| 1 | +# Filters Module |
| 2 | + |
| 3 | +A custom CSS selector matching and DOM observation engine with Shadow DOM support and uBlock Origin procedural cosmetic filter extensions. |
| 4 | + |
| 5 | +## API |
| 6 | + |
| 7 | +### `querySelectorAll(root, selector)` |
| 8 | + |
| 9 | +Returns all elements under `root` matching `selector`, including inside shadow roots. |
| 10 | + |
| 11 | +```ts |
| 12 | +import { querySelectorAll } from './filters' |
| 13 | + |
| 14 | +const elements = querySelectorAll(document.body, '.promoted:has-text("Ad")') |
| 15 | +``` |
| 16 | + |
| 17 | +### `querySelector(root, selector)` |
| 18 | + |
| 19 | +Returns the first matching element, or `null`. |
| 20 | + |
| 21 | +```ts |
| 22 | +const el = querySelector(document.body, '#sidebar .widget:upward(1)') |
| 23 | +``` |
| 24 | + |
| 25 | +### `observe(root, selector, options)` |
| 26 | + |
| 27 | +Watches `root` for matching elements — both existing and dynamically added. Returns a cleanup function. |
| 28 | + |
| 29 | +```ts |
| 30 | +const cleanup = observe(document.body, '.ad-banner', { |
| 31 | + onMatch: (elements) => { |
| 32 | + elements.forEach((el) => el.remove()) |
| 33 | + }, |
| 34 | +}) |
| 35 | + |
| 36 | +// With onUnmatch (for conditional selectors) |
| 37 | +const cleanup = observe(document.body, '.widget:matches-path(/^\\/r\\//)', { |
| 38 | + onMatch: (elements) => { |
| 39 | + elements.forEach((el) => (el.style.display = 'none')) |
| 40 | + }, |
| 41 | + onUnmatch: (elements) => { |
| 42 | + elements.forEach((el) => (el.style.display = '')) |
| 43 | + }, |
| 44 | +}) |
| 45 | + |
| 46 | +// Stop observing |
| 47 | +cleanup() |
| 48 | +``` |
| 49 | + |
| 50 | +Behavior: |
| 51 | + |
| 52 | +- Initial scan results are delivered synchronously |
| 53 | +- Dynamic mutations are batched via `requestAnimationFrame` |
| 54 | +- Each element is reported at most once (deduplicated via `WeakSet`) |
| 55 | +- Shadow roots are detected both on scan and via 500ms polling for lazy creation |
| 56 | + |
| 57 | +## Supported Selectors |
| 58 | + |
| 59 | +### Standard CSS |
| 60 | + |
| 61 | +| Selector | Example | |
| 62 | +| --- | --- | |
| 63 | +| Tag | `div`, `span` | |
| 64 | +| Class | `.foo`, `.foo.bar` | |
| 65 | +| ID | `#myid` | |
| 66 | +| Universal | `*` | |
| 67 | +| Attribute | `[attr]`, `[attr=val]`, `[attr^=val]`, `[attr$=val]`, `[attr*=val]`, `[attr\|=val]` | |
| 68 | +| Descendant | `#parent .child` | |
| 69 | +| Child | `#parent > .child` | |
| 70 | +| Adjacent sibling | `.a + .b` | |
| 71 | +| General sibling | `.a ~ .b` | |
| 72 | +| Comma groups | `.a, .b` | |
| 73 | +| `:not()` | `:not(.hidden)` | |
| 74 | +| `:is()` / `:where()` | `:is(.a, .b)` | |
| 75 | +| `:has()` | `div:has(.inner)` | |
| 76 | +| `:first-child` / `:last-child` / `:only-child` | | |
| 77 | +| `:nth-child()` / `:nth-last-child()` | `:nth-child(2n+1)`, `:nth-child(odd)` | |
| 78 | +| `:nth-of-type()` / `:nth-last-of-type()` | | |
| 79 | +| `:first-of-type` / `:last-of-type` / `:only-of-type` | | |
| 80 | +| `:root` / `:empty` | | |
| 81 | + |
| 82 | +### Extended (uBlock Origin procedural cosmetic filters) |
| 83 | + |
| 84 | +| Selector | Description | |
| 85 | +| --- | --- | |
| 86 | +| `:has-text(text)` | Matches if the element's `textContent` contains `text`. Supports regex: `:has-text(/pattern/flags)`. | |
| 87 | +| `:upward(n)` | Returns the ancestor `n` levels above the matched element. **Must be terminal** (see below). | |
| 88 | +| `:upward(selector)` | Returns the closest ancestor matching `selector`. **Must be terminal** (see below). | |
| 89 | +| `:matches-media(query)` | Matches only when the media query is true. Re-evaluated on viewport changes. | |
| 90 | +| `:matches-path(path)` | Matches only when `location.pathname + location.search` contains `path`. Supports regex. Re-evaluated on SPA navigation. | |
| 91 | + |
| 92 | +`:matches-media` and `:matches-path` are **conditional** — `observe()` automatically re-evaluates them when the environment changes and fires `onUnmatch` when they stop matching. |
| 93 | + |
| 94 | +**`:upward()` must be terminal** — it cannot be followed by a combinator (`+`, `~`, `>`, or descendant space). The matching engine evaluates selectors right-to-left, so `:upward()` can only redirect the final match target. Selectors like `.child:upward(section) + hr` will throw an error at parse time. Use `:has()` instead for sibling selectors: `section:has(.child) + hr`. |
| 95 | + |
| 96 | +## Architecture |
| 97 | + |
| 98 | +```txt |
| 99 | +index.ts Public API re-exports |
| 100 | + │ |
| 101 | + ├── query.ts querySelectorAll / querySelector |
| 102 | + │ │ |
| 103 | + │ └── matches.ts Core matching engine |
| 104 | + │ |
| 105 | + └── observe.ts MutationObserver-based watcher |
| 106 | + │ |
| 107 | + └── matches.ts |
| 108 | +``` |
| 109 | + |
| 110 | +### matches.ts — Matching Engine |
| 111 | + |
| 112 | +Evaluates a single element against a parsed [css-what](https://github.com/fb55/css-what) AST. |
| 113 | + |
| 114 | +- **Right-to-left matching**: Tokens are consumed from the rightmost simple selector, then combinators walk up/across the DOM tree. |
| 115 | +- **Return type**: `Element | null`. Normally returns the input element on match. `:upward()` redirects the return to an ancestor, so callers should use the returned element rather than the input. |
| 116 | +- **Shadow DOM**: The `getParent()` helper crosses shadow boundaries transparently — when an element's parent is a `ShadowRoot`, it jumps to `shadowRoot.host`. |
| 117 | + |
| 118 | +### query.ts — Static Queries |
| 119 | + |
| 120 | +Wraps `matches()` with a depth-first DOM walker (`walkDOM`) that enters shadow roots. Straightforward: parse selector, walk tree, collect matches. |
| 121 | + |
| 122 | +### observe.ts — Dynamic Observation |
| 123 | + |
| 124 | +Layers reactivity on top of `matches()`: |
| 125 | + |
| 126 | +1. **AST splitting** — The parsed selector groups are classified as *unconditional* (pure DOM) or *conditional* (contains `:matches-media` / `:matches-path`). Unconditional elements are tracked in a `WeakSet` (fire-and-forget). Conditional elements are tracked in a `Set` (iterable for re-evaluation). |
| 127 | + |
| 128 | +2. **MutationObserver** — Watches `childList` (subtree) and `attributes` (`class`, `id`) on the root and on discovered shadow roots. |
| 129 | + |
| 130 | +3. **Shadow root polling** — A 500ms `setInterval` walks the tree looking for newly attached shadow roots (Web Components often create them asynchronously in `connectedCallback`). |
| 131 | + |
| 132 | +4. **Environment listeners** — For conditional selectors: |
| 133 | + - Media queries: `matchMedia(query).addEventListener('change', ...)` |
| 134 | + - Path changes: monkey-patches `history.pushState` / `replaceState` and listens for `popstate`. Uses a shared reference-counted install so multiple `observe()` calls don't patch repeatedly. |
| 135 | + |
| 136 | +5. **Re-evaluation** — When the environment changes, `reevaluateConditional()` runs two phases: |
| 137 | + - *Un-hide*: Remove elements from the conditional set that no longer match, call `onUnmatch`. |
| 138 | + - *Re-scan*: Walk the DOM for newly matching elements, call `onMatch`. |
| 139 | + |
| 140 | +6. **Batching** — Mutations within the same frame are coalesced into a single `onMatch` call via `requestAnimationFrame`. |
0 commit comments