Embed Server
Two different things answer on webreader.abair.ie, released two different ways:
- The widget (
/script.jsand the versioned URLs) is served from a Cloudflare R2 bucket by a small Worker, and is released by running one command by hand. Merging tomainnever changes what an embedded site loads. - The help pages (
/,/privacy,/healthz,/assets/*) are served by the Express container, which auto-deploys on every push tomain.
Releasing the widget
From the project folder, after the release PR is merged:
./release.sh --ref main
That builds the merged commit in a throwaway worktree, uploads the bundle to the bucket under its version (v1.3.0/script.js), points the rolling addresses (script.js, v1/script.js) at it, and checks the live URLs. First time on a new machine: npx wrangler@3 login once; the script asks for the Cloudflare account id and remembers it.
Every released version is kept in the bucket permanently at its own URL, and a version's bytes are never rewritten — the publish script refuses if the version already exists with different content. Releasing means bumping the version first (./bump-version.sh); see DEPLOYING.md in the repo for the full walkthrough.
| Stage | Trigger | What happens |
|---|---|---|
| CI | PR to main | Runs npm ci and builds the bundle to verify it still compiles. |
| CD | Merge to main | Builds the Docker image and deploys the help pages over SSH. Not the widget. |
| Widget release | ./release.sh --ref main, run by hand | Uploads the bundle to R2 and moves the rolling URLs. |
Checking what is deployed
Ask for the script URLs themselves:
./release.sh --verify
or manually — the exact version should return 200:
curl -sI https://webreader.abair.ie/v1.3.0/script.js | head -1
The loaded widget exposes window.WebReader.version in the page console.
https://webreader.abair.ie/healthz reports the help-page container, not the widget. Its version and commit describe what the Express image was built from and can lag behind or run ahead of the widget in the bucket — they are independent deployments.
If a release doesn't appear
- Re-run
./release.sh --verify— the CDN can take a minute. - Read the output of the release run: if it said a version already exists with different content, nothing was uploaded — bump the version and release again.
- Most likely cause: the version in
embed/package.jsonwas never bumped, so the run was a harmless no-op re-pointing the rolling URLs at the same build.
Rolling back
Every old version is still in the bucket, so a rollback is publishing the last good version again:
./release.sh --ref <commit or tag of the last good version>
It finds that version already in the bucket, leaves it untouched, and re-points script.js and v1/script.js at it. Sites on the rolling URLs go back within minutes as the CDN cache clears. Sites pinned to a specific version were never affected.
Then fix the problem properly and release a new version — don't leave the rolling URLs on an old build, or the next release will look like it did nothing.
Caching
Pinned version URLs are served immutable with a one-year lifetime — they never change content, so they can be cached forever. The rolling URLs carry a short max-age, but Cloudflare's edge extends it (observed: max-age=14400, 4 hours, plus stale-while-revalidate).
The practical consequence: a release is not visible to every visitor instantly. Allow a few hours, or check with a cache-busting query string.
Embedding the production widget on a site
One-line install
<script src="https://webreader.abair.ie/script.js" async></script>
Put it just before </body>, not in <head>.
Choosing a URL
| URL | Behaviour | Use when |
|---|---|---|
https://webreader.abair.ie/script.js | Always the newest build | Default. You want fixes automatically. |
https://webreader.abair.ie/v1/script.js | Newest 1.x build | You want fixes but not breaking changes. |
https://webreader.abair.ie/v1.3.0/script.js | That exact build, cached for a year | You need a fixed target, or you use integrity hashes. |
Pinning is permanent, and safe. Every released version is stored as its own file and never rewritten, so a pinned URL keeps serving the same bytes indefinitely — an integrity hash taken against it cannot stop matching. The trade-off: a pinned URL never receives fixes, including security fixes, so review it periodically and update deliberately.
Configuration
Set window.WebReaderConfig before the script tag. It is read once, on load.
<script>
window.WebReaderConfig = {
locale: 'ga',
panelPosition: 'bottom-right',
collapseByDefault: true,
speed: 1.0,
dialect: 'ga_CO',
voice: 'ga_CO_snc_piper',
wordLookup: false,
announceMedia: false,
disableTeanglann: false,
keyboardShortcuts: false
};
</script>
<script src="https://webreader.abair.ie/script.js" async></script>
| Option | Type | Default | Description |
|---|---|---|---|
locale | 'ga' | 'ga-IE' | 'en' | 'ga' | UI language of the panel. |
panelPosition | preset string | 'bottom-right' | One of top-left, top-right, bottom-left, bottom-right, remember. |
collapseByDefault | boolean | true | Whether the panel starts collapsed on a visitor's first visit. |
speed | number 0.5–1.5 | 1.0 | Playback speed. First-visit default only. |
dialect | dialect code | 'ga_CO' | ga_CO, ga_UL, ga_MU, ga_MU_ar. First-visit default only. |
voice | voice id | Sibéal | e.g. ga_CO_snc_piper. Sets the matching dialect. First-visit default only. |
wordLookup | boolean | false | Double-click dictionary lookup. First-visit default only. |
announceMedia | boolean | false | Speak "Link:" / "Image:" announcements. First-visit default only. |
disableTeanglann | boolean | false | Removes the lookup feature entirely. Applied on every load, overrides wordLookup. |
keyboardShortcuts | boolean | false | Document-level shortcuts. Applied on every load. |
nonce | string | none | Your CSP nonce, if you serve a strict style-src. |
Every option also has a data- attribute equivalent on the script tag, e.g. data-locale="en" or data-word-lookup="true".
Values marked first-visit default only seed a visitor's settings the first time they load the page. Once they change something in the panel, their own choice wins. disableTeanglann and keyboardShortcuts are site policy and apply on every load.
Invalid values are reported in the browser console with the accepted values listed, rather than being silently ignored.
Defaults that differ from the extension
Three settings are deliberately off in the widget because it runs on a site the reader does not own:
| Setting | Extension | Widget | Why |
|---|---|---|---|
wordLookup | on | off | A double-click anywhere would open a dictionary iframe over your page. |
autoScroll | on | off | Scrolling the window fights parallax, scroll-jacking and infinite feeds. |
keyboardShortcuts | on | off | Alt+←/Alt+→ are Back/Forward, and Alt+letter is your accesskeys. |
Visitors can still turn the first two on from the panel.
JavaScript API
Once loaded, the widget exposes exactly one global, window.WebReader:
| Member | What it does |
|---|---|
version | Version string of the loaded build, e.g. '1.3.0'. |
play() | Starts reading, or resumes after a pause. |
pause() | Pauses, keeping position. |
stop() | Stops and clears the highlight. |
configure(options) | Applies settings at runtime, using the same keys as above. |
destroy() | Removes the widget completely — panel, listeners, audio. |
on(event, handler) | Subscribe. Events: play, stop, complete, error. |
off(event, handler) | Unsubscribe. Omit the handler to remove all for that event. |
WebReader.on('complete', () => console.log('finished reading'));
WebReader.configure({ speed: 1.2 });
Nothing else is published to the page. If you find yourself reaching for an internal object, it is not part of the supported surface and will change without notice.
destroy() is what single-page apps need on unmount.
Content Security Policy
If your site sends a CSP, allow the following:
script-src https://webreader.abair.ie;
connect-src https://synthesis.abair.ie;
frame-src https://www.teanglann.ie;
img-src data:;
| Directive | Needed for |
|---|---|
script-src | Loading the widget. |
connect-src | Speech synthesis. Without it there is no audio at all. |
frame-src | The dictionary popover. Not needed if you set disableTeanglann: true. |
img-src | data: — the inline SVG arrows on the panel's dropdowns. |
Two things you do not need:
media-src— audio plays through the Web Audio API, not an<audio>element.style-src 'unsafe-inline'— the widget applies its CSS with constructable stylesheets, which CSP does not govern. On older browsers it falls back to a<style>element; pass yournonceif you need that path to work under a strict policy.
React / Next.js
Load from a client component so it skips SSR.
"use client";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
export default function Webreader() {
const { i18n } = useTranslation();
useEffect(() => {
const scriptId = "webreader-cdn-script";
if (document.getElementById(scriptId)) return;
const baseUrl =
process.env.NEXT_PUBLIC_WEBREADER_URL || "https://webreader.abair.ie";
(window as any).WebReaderConfig = {
locale: i18n.language === "ga-IE" ? "ga" : "en",
panelPosition: "bottom-right",
};
const script = document.createElement("script");
script.id = scriptId;
script.src = `${baseUrl}/script.js`;
script.async = true;
document.body.appendChild(script);
return () => {
(window as any).WebReader?.destroy();
document.getElementById(scriptId)?.remove();
};
}, [i18n.language]);
return null;
}
Mount <Webreader /> once in the root layout. The scriptId guard prevents double-mounting from route changes, hot reloads, or React StrictMode; the cleanup function tears the widget down properly when the component unmounts.
Environment switching
Use a public env var so the URL is available in the browser bundle:
# .env.local
NEXT_PUBLIC_WEBREADER_URL=http://localhost:3010
Leave it unset in production. Restart npm run dev after changing it — Next.js bakes NEXT_PUBLIC_* vars at server start.
Marking up the host page
Widget behaviour depends on the host page's HTML. Quick wins:
- Wrap content in
<main>or<article>. Anything outside is silently skipped. - Use real
<h1>–<h6>, not styled<div>s. Headings get a longer pause, which helps listeners orient. <a>nested in<p>/<li>/<h*>is handled smartly —"Link: "is injected inline without duplicating the text.<button>in the same position still reads twice — avoid it.- Form labels aren't read. Describe forms in a sibling
<p>if listeners need them.
See Architecture for the full picture of what gets read and in what order.
Verification checklist
- Network:
script.jsreturns 200 from the URL you expect. - DOM: a
<div class="webreader-host">sits at the end of<body>. The panel itself lives inside its shadow root, so you won't see it in the normal element tree. - Globals:
window.WebReader.versionreturns the version you expect, and nothing else has appeared onwindow. - Console: filter by
WebReader— no errors or configuration warnings. - Play test: a homepage, an article, a form, a list view.
- Locale test: flip the language switcher, reopen the panel — UI strings should match.
Common gotchas
| Symptom | Cause / fix |
|---|---|
| Script 404s | The version in the URL was never released — usually a typo, or a partial number like /v1.3/ (only /v1/ or a full /v1.3.0/ are addresses). Released versions are kept forever, so a URL that once worked keeps working. |
| CSP blocks the script | See the CSP section above. |
| Panel appears but no audio | connect-src is missing https://synthesis.abair.ie. Check DevTools → Network for synthesise requests. |
| Panel renders unstyled | A strict style-src on a browser without constructable stylesheet support. Pass nonce. |
| Reads header/footer | Missing <main> — the controller falls back to <body>. |
| Widget appears twice | Duplicate <script> tag, often from hot reload. The scriptId guard prevents it. |
| Nothing happens on a PDF | PDFs are not supported. See Unsupported Content. |
Self-hosting
If you need to host the widget at a different domain, build it as described in Development → Embed Server and serve script.js from any static host. Also serve the help and privacy pages so the in-widget help link still works, and set baseUrl in your config so the widget can find them.
Last updated 2026-08-18