diff --git a/AGENTS.md b/AGENTS.md index b56165f..9773126 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,17 @@ is tracked in the **towl** work-item store under project **`reddit-spotify-linke extractor (Title-Case runs + cue phrases for artists; quoted strings + album cue phrases for albums) — node-testable via `extract.test.cjs`. Album links render italicised (`.rsl-album`). + - **shreddit shadow DOM:** `collectTextNodes` is shadow-piercing. A TreeWalker can't + cross a shadow boundary, so it walks the comment-tree `root`'s light DOM AND + recursively descends into the **open** shadow roots of elements within that subtree. + New reddit's `shreddit-*` components may encapsulate comment bodies in a shadow root; + this reaches them whether reddit slots (light DOM) or encapsulates (shadow DOM), with + no need to know which. It is scoped to the comment-tree root (never the whole + document's shadow roots, which would pull in reddit chrome) and is a no-op on old + reddit (no shadow roots). Slotted light-DOM nodes are not double-collected (a shadow + walker sees the shadow tree's own nodes, not a ``'s assigned light nodes; a + Set de-dupes belt-and-braces). **Closed** shadow roots are unreachable from a content + script — a documented limitation. - `scripts/check.sh` — the local check gate. ## How work lands (IMPORTANT — this is NOT the towl repo) diff --git a/README.md b/README.md index 1b2d931..e6d75d4 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,11 @@ let the content script reach it). name links to a Google search for " bandcamp" (which surfaces the Bandcamp page). - **Without Spotify creds or the LLM, nothing links** — at least one gate must be configured. +- **New reddit (shreddit) shadow DOM** — the comment-text scan pierces the **open** + shadow roots of the `shreddit-*` web components within the comment tree, so it links + comment bodies whether reddit slots them into light DOM or encapsulates them in a + shadow root. **Closed** shadow roots are unreachable from a content script and stay + unlinked (a browser limitation, not a setting). - The optional LLM adds a little latency on the **first** call (ollama warms the model), then is fast. - The resolver caches results in memory; it resets when you restart it. diff --git a/extension/content.js b/extension/content.js index b4e47cc..ec9fec3 100644 --- a/extension/content.js +++ b/extension/content.js @@ -30,10 +30,27 @@ return false; } - function collectTextNodes(root) { - const nodes = []; - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + // New reddit's shreddit-* web components may render comment bodies inside an OPEN shadow + // root. A TreeWalker can't cross a shadow boundary, so collectTextNodes walks `root`'s light + // DOM AND, recursively, every open shadow root of elements within that subtree — so it reaches + // the comment text whether reddit slots it (light DOM) or encapsulates it (shadow DOM). Old + // reddit has no shadow roots, so the recursion is a no-op there. Scoped to the comment-tree + // `root` (getRoot()) — it never walks the whole document's shadow roots, which would pull in + // reddit's chrome/UI text and be unbounded. A slotted light-DOM node is reached only by the + // light walk: a TreeWalker over a shadow root walks the shadow tree's own nodes, not a 's + // assigned (light) nodes, so it isn't double-collected — a Set keyed on node identity is a + // belt-and-braces guard. Closed shadow roots (`.shadowRoot === null`) stay unreachable. + const MAX_SHADOW_DEPTH = 50; // bound the shadow-host recursion; a real comment tree is far shallower. + + function collectInto(root, nodes, seen, depth) { + if (depth > MAX_SHADOW_DEPTH) return; + // SHOW_TEXT to gather candidate text; SHOW_ELEMENT so we can spot shadow hosts to recurse into. + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, { acceptNode(node) { + if (node.nodeType === Node.ELEMENT_NODE) { + // Elements are visited only to discover shadow hosts; never collected as text. + return node.shadowRoot ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; + } const v = node.nodeValue; if (!v || v.length > 5000 || !/[A-Za-z]/.test(v)) return NodeFilter.FILTER_REJECT; if (inSkippable(node)) return NodeFilter.FILTER_REJECT; @@ -41,7 +58,20 @@ }, }); let n; - while ((n = walker.nextNode())) nodes.push(n); + while ((n = walker.nextNode())) { + if (n.nodeType === Node.ELEMENT_NODE) { + // Open shadow host: descend into its shadow tree (and its descendants' shadow trees). + collectInto(n.shadowRoot, nodes, seen, depth + 1); + } else if (!seen.has(n)) { + seen.add(n); + nodes.push(n); + } + } + } + + function collectTextNodes(root) { + const nodes = []; + collectInto(root, nodes, new Set(), 0); return nodes; }