Adds store/ with a reproducible generator for the Web Store listing
screenshots: composites the popup capture onto an on-brand (readest.com)
background with a headline, renders via headless Chromium, and writes 24-bit
no-alpha PNGs at 1280x800 and 640x400 into store/out/ (gitignored).
- store/generate.mjs - config + compositing + render (Playwright + ImageMagick)
- store/popup.png - raw popup capture (700x508); its status strip is blanked
and re-lettered from CONFIG so it stays in sync with code
- store/README.md - usage + requirements
- package.json - adds `pnpm store:screenshots`
- .gitignore - ignores store/out/
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Send to Readest — browser extension
One-click capture of the current web page into your Readest library as a self-contained EPUB. Built for Chromium-based browsers (Chrome, Edge, Arc, Brave). Manifest V3.
What it captures
For every page the user clips, the extension produces a single .epub:
- Article body via @mozilla/readability on the live, fully-rendered DOM — so paywalled / authenticated pages capture the content the user can actually see.
- Lazy-loaded images surfaced by walking the DOM and resolving
srcset,<picture>/<source>,data-src,data-original,data-srcset,data-lazy, anddata-actualsrcin that order of preference (the same set the server-side bundler insrc/services/send/conversion/assetBundler.tshandles). - Image bytes fetched by the service worker under the user's existing
session (
credentials: 'include') and the extension's broadhost_permissions: ["<all_urls>"]— CORS doesn't apply, paywalled CDN cookies do. - A small bundled stylesheet (system fonts only — no remote fonts), so the EPUB never makes a network request when opened offline.
- Inline images stored under
OEBPS/images/<sha256>.<ext>, deduplicated by hash so a hero shared between<picture>and<img>only ships once.
The EPUB is POSTed to POST /api/send/inbox/file with kind=file. The
server writes the bytes to R2 and inserts a send_inbox row; the next
Readest client to open drains the inbox and imports the EPUB as-is — no
further server-side conversion.
Why client-side conversion
The previous version (0.1.0) sent only the page URL — the server then
tried to fetch and render it. That broke on:
- Paywalled / member-only content (server doesn't have the user's cookies).
- Bot-protected CDNs (Cloudflare, image hosts that gate on UA + Sec-Ch-Ua headers + JS challenges).
- Lazy-loaded images that never materialize without a real scroll.
Building the EPUB on the capturing client side-steps all three. See D5 in the Send to Readest plan and Part 4a / Part 8 of the plan for the original design.
Architecture
popup (popup.ts)
│ click "Send to Readest"
▼
service worker (background/service-worker.ts)
│ chrome.scripting.executeScript({ files: ['content/capture.js'] })
▼
content script (content/capture.ts) [runs in the page's tab]
├─ scrolls page once to materialize lazy images
├─ flattens open Shadow DOM (content/capture/shadow.ts)
├─ Readability extracts article body
├─ walks <img>/<picture>, rewrites src → placeholder tokens
├─ DOMPurify-sanitizes the article HTML
└─ returns { meta, articleHtml, images:[{placeholder,url}] }
▼
service worker
├─ fetchAssets(images) — CORS-free, credentialed image downloads
├─ buildEpub — zip.js: mimetype, container, OPF, NCX, CSS, chapter, images
└─ uploadEpub — POST /api/send/inbox/file with EPUB bytes
▼
server (src/pages/api/send/inbox/file.ts)
├─ putObject → R2 (inbox bucket, kind='file')
└─ insert send_inbox row
▼
next Readest open → drainer imports the EPUB → book in library on all devices
A second always-on content script (content/auth-bridge.ts) runs only on
web.readest.com and copies the user's Supabase access token into the
extension's chrome.storage.local so the popup can authenticate to the
inbox endpoint without prompting for credentials. The extension never
stores a password or refresh token.
Build
# From the extension directory:
pnpm install # one-time — the extension is a pnpm workspace package
pnpm build # produces dist/ ready to load unpacked
pnpm dev # watch mode while developing
The build is webpack-based:
src/background/service-worker.ts→dist/background/service-worker.js(bundles@zip.js/zip.js)src/content/capture.ts→dist/content/capture.js(bundles@mozilla/readability+dompurify)src/content/auth-bridge.ts→dist/content/auth-bridge.jssrc/popup/popup.ts→dist/popup/popup.js
manifest.json, popup.html, and icons/* are copied verbatim into
dist/ by copy-webpack-plugin.
Load it for development
pnpm build(orpnpm devfor watch mode).- Open
chrome://extensions, enable Developer mode. - Load unpacked → select this directory's
dist/folder (not the project root). - Visit https://web.readest.com once and sign in so the auth-bridge content script captures the access token.
- Click the extension's toolbar icon on any article page. The popup reflects each phase: capturing → fetching images → building EPUB → sending.
Pointing the extension at a local Readest
The extension reads chrome.storage.local.readestApiBase if set, falling
back to https://web.readest.com. From the DevTools console of the
extension's background page:
chrome.storage.local.set({ readestApiBase: 'http://localhost:3000' });
Testing
Vitest exercises the extension's shell — upload (X-Readest-* headers, RFC
5987 encoding, error-code mapping, endpoint override), auth bridge
(sb-*-auth-token localStorage → chrome.storage.local sync, including
malformed JSON + storage-event rotation), chrome.storage auth helpers,
toolbar badge updates, the lazy-load scroll dance (incl.
prefers-reduced-motion), and the popup UI rendering for every progress
phase. From the apps/readest-app workspace root:
pnpm test:extension # 47 shell tests, ~1 s
pnpm build-browser-ext # production webpack build (catches alias / stub regressions)
pnpm test # full suite — also runs the extension tests via vitest's default glob
The shared EPUB pipeline (convertPageToEpub) and the server's
/api/send/inbox/file endpoint have their own tests under
apps/readest-app/src/__tests__/services/. The unification regression
specifically lives in send-convert-page-unified.test.ts.
CI coverage: the GitHub Actions test_web_app job runs
pnpm test:pr:web, which invokes the full vitest suite (extension shell
tests included), the browser-test suite, and the extension's production
webpack build. A webpack-config or shared-pipeline regression fails the
job on its own line.
Internationalisation
The extension uses key-as-content i18n (matches the readest-app's
stubTranslation as _ convention): the English source string IS the
lookup key. Import as _ at every call site to mirror the main repo:
import { translate as _ } from '../lib/i18n';
_('Send to Readest');
_('Sent — {count} images could not be fetched.', { count });
Two parallel translation surfaces:
| Folder | Scope | When it's read |
|---|---|---|
src/locales/<lang>.json |
29 runtime UI strings — popup, errors, status, badges. { "<english source>": "<translation>" }. |
At runtime by the _(...) helper. Falls through to the English key when an entry is missing or set to the __STRING_NOT_TRANSLATED__ sentinel. |
_locales/<lang>/messages.json |
Three manifest fields — app_name, app_description, action_title — referenced as __MSG_*__ in manifest.json. |
At install time + by the Chrome Web Store listing. Chrome falls back to default_locale (en) automatically, so a locale file is only needed when you want to override the toolbar tooltip / store copy. |
The full set of supported locales lives in
apps/readest-app/i18n-langs.json. The extension's extractor reads the
same file, so a locale added there ships in the extension automatically
on the next pnpm i18n:extract run.
Extracting strings
After adding _('...') calls or data-i18n="..." attrs:
pnpm i18n:extract # populates every src/locales/*.json with new keys
pnpm i18n:check # exits non-zero if any bundle has untranslated entries
The extractor:
- Reads the canonical locale list from
apps/readest-app/i18n-langs.jsonand ensures a stubsrc/locales/<lang>.jsonexists for every entry (creates an empty{}for any missing locale). - Scans every
.ts/.tsx(skipping*.test.ts) and every.htmlfile under the extension. - Pulls source strings from
_('…')calls ANDdata-i18n/data-i18n-titleHTML attributes. - For every non-
enlocale: adds missing entries with__STRING_NOT_TRANSLATED__(same sentinel readest-app uses), keeps existing translations, sorts keys for deterministic diffs. - Regenerates
src/locales/index.tswith one static import per locale bundle — the runtime helper reads itsbundlesmap. - Mirrors the locale list into
_locales/<lang>/messages.jsonstubs (Chrome's native i18n surface for manifest fields). Existing translations are never overwritten — only missing locales get created as__STRING_NOT_TRANSLATED__stubs. - Logs orphan entries (in the bundle but no longer in code) so a translator can decide whether to drop them.
The runtime helper filters sentinel entries at load time, so a partially-translated bundle gracefully falls back to English per-key instead of leaking placeholders into the UI.
Adding a locale
To add a locale the extension ships in lockstep with the main app:
- Add the code to
apps/readest-app/i18n-langs.json. pnpm i18n:extractfrom the extension dir — createssrc/locales/<code>.jsonpopulated with__STRING_NOT_TRANSLATED__placeholders, regeneratessrc/locales/index.ts.- Hand-translate each value.
- (Optional) Drop
_locales/<code>/messages.jsonmirroring the en file if you want the toolbar tooltip / Chrome Web Store listing translated too. Chrome falls back toenwhen this is missing. - Rebuild — webpack picks up the new JSON via the regenerated index.
Before publishing to the Chrome Web Store
- Replace the icon set (currently a 1:1 downscale of the Readest app icon) with extension-specific artwork.
- Add a screenshot bundle and a privacy disclosure: the extension reads the page DOM and fetches its image references; nothing is sent off-device except to the Readest inbox.
- Submit through the Chrome Web Store Developer Dashboard (review required).