Architecture
Layers at a glance
| Layer | Files | Role |
|---|---|---|
| Content scripts | content/, shared/ (chunker, i18n) | Extract, chunk, play, highlight — runs in the host page |
| Panel UI | content/floating-panel.js + .css | All controls, isolated in a Shadow DOM so host CSS cannot restyle them |
| Style injection | shared/style-inject.js | Applies the widget's CSS without requiring style-src 'unsafe-inline' |
| Editor adapters | content/adapters/ | Google Docs and Word for the web — see Hosted Editors |
| Word lookup | content/lookup-popover.js | Teanglann dictionary popover — see Word Lookup |
| TTS fetch | background/ (extension) or embed/src/shim.js (widget) | Talks to the TTS API; prefetches upcoming chunks |
| Embed server | embed/ | Serves the widget bundle at webreader.abair.ie |
| i18n | locales/resources.json + shared/i18n.js | Translation strings — source of truth in resources.json, managed via the Translations Editor |
| Manifests | manifest.json / manifest-firefox.json / -safari.json | Chrome MV3, Firefox MV2, Safari MV2 (extension only) |
The extension and embed widget share the entire content-script layer. Only the TTS fetch path and the host glue differ.
Reading pipeline
When Read this page fires:
- Pick a container — first match wins:
article → main → [role="main"] → .post-content → … → body. Anything outside is silent. - Collect candidates — one
querySelectorAll('p, h1–h6, li, td, th, blockquote, button, a, figcaption'), plusimg[alt]if Announce media is on. - Filter — drop hidden, in-panel, in-Shadow-DOM, and editable elements. Headings need at least 1 character; everything else at least 2.
- Cap — extraction stops at
MAX_PAGE_CHARS(50,000). Longer pages are truncated and the panel says so. - Chunk each candidate by sentence (default) or paragraph; the chunker handles abbreviations and inline
<a>annotations. - Pause between chunks — headings 600 ms, block prose 250 ms, other 0.
- Prefetch + play — TTS the next two while the current plays; the highlighter syncs words back to the DOM via XPath.
How highlighting touches the page
The highlighter wraps the page's own text in <span> elements in order to colour it. Two rules keep that safe on a page the widget does not own:
- Editable regions are read but never highlighted. Anything inside
[contenteditable]is skipped by the DOM mapping. Rich-text editors such as ProseMirror, TipTap, Quill and CKEditor watch their own DOM, and would otherwise fold the injected spans into the saved document. - Unwrapping restores the original text nodes. Wrapping uses
Text.splitText(), and clearing puts the pieces back into the node they came from. Text-node boundaries that existed before the widget ran are never merged, which is what lets frameworks such as React keep their references to those nodes.
Things integrators must know
- Reading order = DOM order. CSS reordering (flex
order, grid placement, absolute positioning) is invisible. - Nested
<a>is deduplicated. Card patterns (<a>wrapping<h2>+<p>) collapse to one chunk. Inline links in prose inject"Link: "into the parent's audio instead of duplicating the text. - Still duplicates:
<button>in<p>/<li>, nested<li>, and bare-link containers (<h2><a>X</a></h2>). - Form fields are silent.
<input>,<select>,<textarea>,<label>aren't in the selector. Describe forms in a sibling<p>if listeners need them. <div>and<span>are invisible to the chunker. Wrap meaningful text in one of the tags above, or add a visually hidden<p>.aria-hidden="true"is ignored. Onlydisplay/visibilityfilter elements.- Very short text is dropped. Single decorative characters (bullets, arrows) are skipped, while short Irish headings such as "Pobal" are kept.
Drilling in
| Want to understand… | Read |
|---|---|
| Smart link handling | shared/chunker.js → buildInlineAnnotatedText |
| Pipeline orchestration | content/content.js → getTextNodes, playCurrentChunk |
| Word-level highlight sync | content/highlighter.js → startWordSync |
| Non-destructive wrap / unwrap | content/highlighter.js → wrapTextInNode, clear |
| CSP-safe stylesheet injection | shared/style-inject.js → adopt |
| Extension boot vs widget boot | background/service-worker.js vs embed/src/boot.js |
| Message API between content and BG | synthesize, prefetch, cancelPrefetch, audioData, audioError |
Last updated 2026-07-22