Reveal URLs — Architecture
This document describes how the Reveal URLs extension is structured and how its
core logic works, for contributors and technical readers. It cites real files
and symbols throughout (in the form path symbol) so any claim can be checked
against the source. For the user-facing description see the manual; for the
manual verification steps see the manual test plan.
Overview
Reveal URLs is a single MV3 WebExtension built for several engines — Chrome,
Edge, Opera, Firefox and Thunderbird — from one shared, pure code core. (Safari
is deferred: it has a manifest and is buildable when named explicitly, but is
excluded from the default build and from CI.) The extension reveals each link's
real destination next to the link in a rendered email, and flags a link whose
visible text names a different registrable domain from its href — the classic
phishing tell.
The repository is a pnpm/TypeScript monorepo (pnpm-workspace.yaml,
package.json with "private": true). Bundling is a single esbuild-driven
script, tooling/build.mjs, which compiles the shared packages and each
engine's thin entry points into a loadable dist/<target>/ directory.
webextension-polyfill is bundled into every script rather than relied on as a
runtime global, so the same source runs unchanged across Chromium and Gecko.
Two native email add-ons extend the same detection core to surfaces the
WebExtension cannot reach: an Outlook Add-in (Office.js task pane, reaching
Outlook on web/Windows/Mac/iOS/Android and Outlook.com) and a Gmail Add-on
(Apps Script CardService, reaching the Gmail web/Android/iOS apps). Both reuse only
the PURE analysis — a new packages/core/src/findings.ts module
(analyseAnchors/analyseHtml, a platform-neutral Finding[] model) that reuses
the existing hostMismatch logic and tldts registrable-domain derivation. Only
the host adapter (how the body is read and parsed) and the presentation differ per
surface: the Outlook task pane parses the body with its own DOMParser and runs
client-side; the Gmail add-on parses with node-html-parser and runs
server-side on Google's Apps Script V8 infrastructure (free hosting, since
Google already holds the mail; deployed local-interactively via clasp). Because
neither framework can mutate the rendered, read-mode message DOM (Office's
setAsync/prependAsync are compose-only; CardService renders cards, not message
HTML), both add-ons present a panel/card of findings, not inline annotation —
the existing linkProcessor.ts DOM-mutation path and REVEAL_URLS_CSS are NOT
reused by either add-on. A relative href is resolved only against a trustworthy
in-email <base href> and otherwise skipped, never against the mailbox/provider
origin (which would fabricate a destination).
The codebase separates concerns sharply:
packages/coreholds PURE logic — no browser APIs, no storage, no markup sinks. It is unit testable in plain JavaScript and is the single source of truth for link annotation, config validation, match-pattern matching, host comparison and contrast-aware colouring.packages/webextwraps the WebExtension APIs around that core: the content lifecycle, dynamic content-script registration, the options UI, the background wiring, the toolbar action and the Thunderbird message-display path.extensions/<engine>holds the thin per-engine entry points plus each engine'smanifest.json.toolingholds the build, version-stamping and icon-generation scripts.featuresholds the Cucumber BDD scenarios and their test doubles.
Repository layout
packages/core — the pure core
Every module here imports no extension API (browser.*/chrome.*/messenger.*)
and uses no string-to-markup DOM sink (innerHTML/insertAdjacentHTML). That
contract is mechanically enforced by packages/core/test/purity.test.ts, which
globs every src/**/*.ts, strips comment bodies via its own stripComments
(so the docblocks that legitimately NAME the forbidden tokens do not trip the
guard) and then asserts that neither EXTENSION_API_PATTERN nor
UNSAFE_DOM_PATTERN matches any module. The public surface is re-exported from
packages/core/src/index.ts. The only runtime dependency is tldts (for
public-suffix-aware domain comparison).
The modules are:
packages/core/src/config.ts— theConfig/SiteRuleschema, defaults and the single validation funnelnormaliseConfig.packages/core/src/matchPattern.ts— the match-pattern grammar, matcher, specificity ranking and granted-origin coverage.packages/core/src/linkProcessor.ts— the document-scoped annotator.packages/core/src/hostMismatch.ts— the registrable-domain mismatch check.packages/core/src/styles.tsandpackages/core/src/contrast.ts— the injectable stylesheet and contrast-aware colouring.packages/core/src/safeColour.ts— the safe-colour predicateisSafeColour.
packages/webext — the browser-API wrappers
Modules here MAY use browser.* and are the only place the WebExtension APIs are
touched. The public surface is re-exported from packages/webext/src/index.ts.
The modules are: content.ts (content lifecycle), contentRegistration.ts
(dynamic registration), storage.ts (config persistence), background.ts
(on-install behaviour), toolbarAction.ts (toggle + badge), messageDisplay.ts
and messageDisplayBackground.ts (Thunderbird), and options/options.ts (the
settings page controller).
extensions/<engine> — thin entry points and manifests
Each engine carries its own manifest.json and icons/. Chrome, Firefox and
Thunderbird additionally ship a src/ of entry points; Edge, Opera and Safari
carry only a manifest and icons and REUSE Chrome's src/ (declared by the build
descriptor — see below). The entries are deliberately tiny: for example
extensions/chrome/src/content.ts is just
void createContentController().bootstrap();, and
extensions/chrome/src/background.ts calls registerBackground,
registerContentReconciliation and registerToolbarToggle. Firefox's
background entry is identical to Chrome's; Thunderbird's
(extensions/thunderbird/src/background.ts) calls registerBackground and
registerMessageDisplay instead (it has no content scripts). extensions/_template
is a copy-ready Chromium scaffold, not a build target.
tooling, features, and the smoke tests
tooling holds build.mjs, version.mjs and icons.mjs. features holds the
Gherkin scenarios, their step definitions and the faithful test doubles in
features/support/webext.ts and features/support/world.ts. Each engine also
carries a extensions/<engine>/test/manifest.smoke.test.mjs (_template's is
template.smoke.test.mjs) — these are the authoritative manifest contract (see
"Per-engine build & the manifest contract" below).
The reveal pipeline
The content path begins at an engine entry such as
extensions/chrome/src/content.ts, which calls createContentController().bootstrap()
from packages/webext/src/content.ts.
Bootstrap
createContentController bootstrap runs once per frame:
- It claims the frame synchronously, before any
await, viaclaimBootstrap, which reads/sets theBOOTSTRAP_MARKERflag on the frame'swindow. A static built-incontent_scriptsentry and an overlapping dynamic user script can both injectcontent.jsinto the same frame; the shared isolated-worldwindowmakes this the correct once-only guard, and claiming before anyawaitmeans two near-simultaneous injections cannot both pass it. - It loads the persisted config FIRST via
loadConfigWithRetry(BOOTSTRAP_CONFIG_RETRIESextra attempts on a transient rejection), holding the marker across the retries so an overlapping injection that already no-op'd is not stranded. Only once the read succeeds does it inject the stylesheet (injectStyles, which assignsREVEAL_URLS_CSSviatextContent), so a failed read appends no<style>and a retry cannot duplicate it. - If config load fails on every attempt,
releaseBootstraprolls the marker back (only here, before annotation starts) so a later re-injection can retry; oncestarthas run the marker must persist, because a fresh annotator would not own the prior pass's nodes. - When
config.enabledit callsstart, and it always registers anonConfigChangedlistener that re-applies config (covering the disabled→enabled transition).
Resolving the active site rule
start (and applyConfig) calls resolveSiteRule to decide whether — and where
— to annotate. resolveSiteRule first tries the running document's OWN location
via resolveForHref, which filters config.sites to the enabled rules and
defers to core's selectMostSpecific so the most specific match wins (a specific
built-in beats a broad user wildcard). When the own location matches nothing AND
the document's own origin is opaque or inherited — gated by hasOpaqueOrigin,
true only for about:blank/about:srcdoc (inherited via INHERITED_ORIGIN_URLS)
or blob:/data: (OPAQUE_ORIGIN_SCHEMES) — it falls back, in order, to the
TOP frame (readTopHref, the built-in Proton message-iframe case), the OPENER
window (readOpenerHref, the Outlook pop-out reading window opened via
window.open("about:blank")) and finally document.referrer (readReferrer).
Every external read is guarded against cross-origin access (which throws) and
contributes a candidate only when readable. An ORDINARY unmatched document is
authoritative and never inherits a rule from ambient context, leaving the
controller inert.
Scoping to the content root and observing mutations
start records the matched rule's contentRoot selector, creates the annotator
via createAnnotator(config), and processes each matched root from
selectRoots(doc, contentRoot) (a querySelectorAll wrapped in try/catch so an
invalid selector fails safe to []). It then observes doc.body (a stable
container) with OBSERVER_OPTIONS ({ childList: true, subtree: true }).
The observer feeds processMutations, which gates every mutation on the content
root: it re-processes a mutation target only when it is an Element within a
root (isWithinRoot, via closest), and routes each added Element through
processAddedNode, which annotates a node that is inside a root, IS a root, or
CONTAINS one (a lazy SPA mount). So in-root churn and a sibling message body
mounting later are both caught, while app chrome outside the roots is left
untouched. A null annotator (the extension is stopped) is a no-op.
applyConfig reconciles a live config change: it tears down when no rule matches
or the enabled flag is off; restarts (revert + re-observe) when the resolved
contentRoot changed; and otherwise reflows in place — annotator.setConfig
then annotator.process over each matched root — without restarting the
observer.
Annotating a single anchor
The annotator lives in packages/core/src/linkProcessor.ts. createAnnotator
returns an Annotator over closure state the page cannot read: a strong Map
keyed by anchor identity, a per-annotator data-ru token from
generateToken (crypto.getRandomValues), and the current config. Its
process(root) method:
- Prunes registry entries whose anchor is no longer connected.
- Collects candidate anchors with
collectAnchors(descendanta[href]plus the root itself when it is ana[href], de-duplicated). - Resolves each anchor's destination with
resolveAnnotatableUrl, which returnsnull(skip) for an empty/in-page href, an unparseable href, any scheme other thanhttp:/https:, or a host equal to or under anignoreHostsentry; it resolves relative hrefs againstbaseURIso the result is absolute. - Takes the idempotent fast path when
isAnnotationIntactconfirms the render fingerprint is unchanged and every created node is still connected and in position; otherwise reverts the stale annotation and re-renders. - Computes mismatch with
hostMismatchon the clean anchor text, honoursshowOnlyOnMismatch(leaving honest links untouched), and callsannotateAnchor.
annotateAnchor renders SAFE-DOM only. In "inline" mode it prepends a
<span class="reveal-urls-url"> whose URL is set via textContent (prefixed with
an arrow and a U+00A0 non-breaking space, truncated by truncateUrl) followed by
a <br>, leaving the link's own children untouched; the connected span then gets
a contrast backdrop via applyContrastBackdrop. In "title" mode it saves the
original title (TitleSave), overwrites it with the URL — the only write to
the anchor — and, on an emphasised mismatch, appends a sibling
<span class="reveal-urls-warn"> badge bearing REVEAL_URLS_WARN_LABEL. Every
created node is tagged data-ru="<token>" (never the anchor), and each entry
records its configFingerprint so a render-config change forces a reflow. Colours
and font overrides are applied through typed element.style.* properties
(applyColour/applyFontOverrides), never interpolated into a CSS string.
revertOwned removes created nodes by reference and restores the saved title;
revertAll does this for every owned anchor and clears the registry.
truncateUrl operates over Unicode CODE POINTS ([...displayHref]), so a cut
never splits a surrogate pair; it preserves the scheme-and-host origin and
shortens the rest with a trailing URL_ELLIPSIS.
Host mismatch
packages/core/src/hostMismatch.ts hostMismatch compares the link's visible
text against its href by registrable domain, not raw hostname, so an honest
sub-domain is not flagged while a look-alike is. extractHostCandidates splits
the text and reduces each token via hostCandidate (which requires a dot and a
tldts-recognised ICANN public suffix, rejecting ordinary dotted text like
e.g). Both the href host and each candidate are reduced to their registrable
domain with tldts.getDomain, and ANY candidate whose domain differs from the
href's yields a mismatch — so naming the real malicious host alongside a lure host
cannot suppress the warning. It never throws.
Styles and contrast
packages/core/src/styles.ts holds the injectable REVEAL_URLS_CSS (fixed layout,
weight and shape; colours are NEVER interpolated into it) and the runtime
applyColour/applyFontOverrides/applyContrastBackdrop helpers.
applyContrastBackdrop reads the element's resolved colour and the first opaque
ancestor background through the standard getComputedStyle (sourced from
element.ownerDocument?.defaultView, so it is frame-safe and stubbable) and,
when packages/core/src/contrast.ts needsWhiteBackdrop reports the text fails
WCAG AA (CONTRAST_THRESHOLD) against that background AND white genuinely helps,
sets style.backgroundColor = "white". contrast.ts provides parseColour,
relativeLuminance and contrastRatio; it parses the rgb()/rgba()/hex forms
getComputedStyle returns and treats a fully transparent colour as "not found".
Configurable hosts & content scoping
A SiteRule (packages/core/src/config.ts) says WHERE annotation runs (match,
allFrames) and WHICH container scopes it (contentRoot), plus enabled and a
builtin flag. The shipped DEFAULT_SITES cover Gmail, Proton (with
allFrames) and both Outlook hosts. The Config aggregates the global toggles,
colours, font overrides and the sites array.
The match-pattern engine
packages/core/src/matchPattern.ts holds a deliberately RESTRICTIVE add-time
grammar MATCH_PATTERN (http/https only, optional leading *. host wildcard,
glob path; no port, no * scheme, no <all_urls>). matchesPattern matches a
URL against a validated pattern, delegating the path to pathGlobMatches (each
* matches any character sequence); it fails safe to false.
parsePatternParts splits a validated pattern into host/path/scheme/
wildcardHost.
Overlapping rules resolve by "most specific wins":
compareSiteSpecificity ranks by (1) exact host over wildcard host, (2) longer
literal host, (3) more-literal path (literalPathLength), then (4) a
deterministic ASCII tiebreak; selectMostSpecific returns the most specific
enabled rule matching a URL.
Granted-origin coverage is a SEPARATE, deliberately BROADER matcher.
permissions.getAll() may report grants in the full WebExtension grammar
(<all_urls>, *://*/*, https://*/*, *://*.host/*, https://host/*) that
the restrictive MATCH_PATTERN would reject. parseGrantedOrigin parses those
into GrantedOriginParts, and originCovers reports whether a granted origin
covers a rule's match (e.g. a broad https://*/* grant genuinely covers every
https rule). matchAllowsOriginFallback reports whether a rule's path is exactly
ORIGIN_FALLBACK_PATH (/*) — the safe intersection of the Chromium and
Gecko/Firefox 128 constraints on the dynamic opaque-origin fallback.
Dynamic registration
packages/webext/src/contentRegistration.ts registers one dynamic content script
per user-added rule. Built-ins are served by the static content_scripts entries
and are NEVER dynamically registered. desiredContentScripts filters
config.sites to non-builtin, enabled rules whose origin isOriginGranted
(delegating to originCovers), maps each to a RegisteredContentScript with a
stable id (contentScriptId, an FNV-1a hash of the match so the id uses only the
API-safe character set), and sets matchOriginAsFallback: true ONLY when
matchAllowsOriginFallback(rule.match) holds (a /* path). Gating the flag this
way prevents one non-conforming-path rule from causing the whole batch
registration to be rejected; such a rule registers without pop-out coverage by
design.
reconcileContentScriptsOnce reads the granted origins, the stored config and
the live registrations, then converges by a PROPERTY-AWARE diff:
sameRegistration projects both the desired descriptor and the live read-back
onto a normalised form (applying each field's real WebExtension default —
allFrames/matchOriginAsFallback default false, persistAcrossSessions
defaults TRUE, runAt defaults document_idle) so a freshly-registered script
compares EQUAL to the descriptor that produced it and no re-registration loop can
arise. Because there is no updateContentScripts, a changed script is
unregistered then re-registered. reconcileContentScripts wraps this with an
in-flight promise (inFlight) so two near-simultaneous triggers (a config change
AND permissions.onAdded both firing on a site add) are serialised rather than
racing. registerContentReconciliation wires the triggers
(permissions.onAdded/onRemoved, onConfigChanged) and converges once on
startup; it is imported only by the Chrome and Firefox background entries.
The opt-in permission flow
The static manifests declare optional_host_permissions (http://*/*,
https://*/*) so a user can grant an arbitrary additional web host at runtime.
packages/webext/src/options/options.ts addSite validates the WHOLE candidate
rule (match AND content-root) through normaliseSiteRule BEFORE requesting any
permission, derives the host origin with matchOrigin, and calls
browser.permissions.request from within the Add button's click gesture; only on
grant does it append a builtin:false row and persist through the canonical write
path.
Config, storage & the canonical pattern
The single validation funnel
normaliseConfig in packages/core/src/config.ts is the one canonical read path:
it coerces and bounds every field, falling back to the default on anything
invalid, and never throws. It is built from per-field validators —
normaliseBoolean, normaliseRenderMode, normaliseMaxLength,
normaliseIgnoreHosts (reducing each entry to a bare punycode hostname via
bareHostname), normaliseMatchColour/normaliseMismatchColour (gated by
safeColour.ts isSafeColour), normaliseCssSize, normaliseFontWeight,
normaliseContentRoot (bounded by CONTENT_ROOT_PATTERN/CONTENT_ROOT_MAX_LENGTH),
normaliseMatchPattern (gated by MATCH_PATTERN) and normaliseSites.
normaliseSites drops rejects, de-duplicates by match, FORCES builtin: true
on any rule whose match equals a built-in's (closing the
exact-match-shadows-built-in / double-inject vector), re-seeds any missing
built-in (so a tampered store cannot drop a core provider) and sorts
alphanumerically by match for a canonical order. configFingerprint hashes
only the render-affecting fields (excluding enabled, ignoreHosts and sites,
which drive start/stop and skip rather than reflow) via fnv1a.
Storage
packages/webext/src/storage.ts wraps the WebExtension storage API.
configArea uses browser.storage.sync when available (so settings roam) and
falls back to browser.storage.local (e.g. on Thunderbird); configAreaName
reports which is active. getConfig, setConfig and onConfigChanged all
funnel their raw value through normaliseConfig, so a malformed or tampered store
can never hand an invalid Config to a caller. onConfigChanged additionally
ignores events from the INACTIVE storage area and changes to unrelated keys.
The two add-ons reuse the same read -> normaliseConfig -> return funnel over their
own host stores. The Outlook task pane's extensions/outlook/src/roamingStorage.ts
wraps Office.context.roamingSettings (synchronous get/set, committed with
saveAsync); the Gmail add-on's packages/gmail/src/propertiesStorage.ts wraps
PropertiesService.getUserProperties() (per-user, roaming across that user's
devices, JSON-encoded under one key, and needing NO extra OAuth scope — never the
shared ScriptProperties). Both expose getConfig/setConfig plus the card-relevant
getIgnoreHosts/getHighlightMismatch accessors and guard an unavailable host
surface with a dedicated error. The Gmail homepage trigger renders a CardService
settings card from getConfig, and the onSaveSettings form-submit handler merges
the parsed ignoreHosts/highlightMismatch onto the current config and persists it
through setConfig; the contextual trigger threads ignoreHosts into the adapter
and highlightMismatch into the card builder, reading defensively so a storage
failure falls back to defaults rather than breaking message analysis.
The canonical-config + targeted-update pattern
Three write paths read the canonical config, change exactly one thing and write it back — never committing unsaved form edits or re-rendering the form:
removeSite(options.ts): readsgetConfig, persistssetConfigover asitesarray with only the removed rule filtered out (persist FIRST, thenrow.remove(), then a conditionalpermissions.removedecided from the next config). A persist failure throwsSiteRemovalSaveErrorand leaves the DOM untouched; a failed revoke throwsSitePermissionRevokeErrorafter the row is already gone.addSite(options.ts): described above — validate, request permission, append the row, thensaveOptions.toggleEnabled(options.ts): the instant master Enable switch flips ONLYenabledon the stored config and writes it back, without re-rendering (so an in-progress edit is never clobbered); on a failed write it reverts the checkbox to the stored value.
The toolbar action's own toggleEnabled
(packages/webext/src/toolbarAction.ts) follows the same pattern from the
background side.
Per-engine build & the manifest contract
tooling/build.mjs drives a single esbuild build for every WebExtension target. The
TARGETS descriptor names each target's SOURCE TARGET: Chrome, Firefox and
Thunderbird ship their own src/; Edge, Opera and Safari declare chrome as their
source; the Outlook (Office.js) add-in is its own source. ACTIVE_TARGETS (Chrome,
Edge, Firefox, Opera and Thunderbird — Safari and Outlook are excluded) is what
--all/--package build; Safari and Outlook build only when named explicitly, and
the Gmail (Apps Script) add-on is a separate esbuild bundle (make build-gmail)
outside the --all loop. That Gmail bundle targets Apps Script's V8 runtime, which
has neither ES modules nor a native URL: it is built as ESM and then has its
trailing export {…} statement stripped (so the trigger functions stay top-level
globals Apps Script can invoke), and it bundles a small URL polyfill (installed only
when URL is absent) so the shared core's new URL(...) resolves links server-side.
buildTarget cleans dist/<target>/, copies the per-target manifest.json and
icons/ verbatim, bundles each source script as IIFE (the injected
INJECTED_SCRIPTS content.ts/messageDisplay.ts cannot be ES modules, and
backgrounds are classic workers/event pages too) and the shared options page as
ESM, and copies options.html/options.css from packages/webext/src/options/.
webextension-polyfill is bundled into every script.
tooling/version.mjs stamps versions: it takes MAJOR.MINOR from the root
package.json and appends an auto-incrementing BUILD number (read from the
highest existing third component across the target manifests) into every target
manifest, keeping them in lockstep. Most targets carry a single manifest.json; the
Outlook add-in carries two versioned manifests (manifest.json and manifest.xml)
and both are stamped, and the _template scaffold is stamped in lockstep too. The
root package.json is kept at the same version, but stays valid semver (a four-part
version is allowed in a manifest, never in package.json). tooling/icons.mjs
(run via make icons) rasterises the single vector source assets/icon.svg into
each target's icons/icon48.png and icons/icon128.png, trying cairosvg, then
inkscape, then ImageMagick.
The manifest contract — the exact required fields per engine — is the single
source of truth in each extensions/<engine>/test/manifest.smoke.test.mjs (e.g.
extensions/chrome/test/manifest.smoke.test.mjs's assertManifestShape, which
also builds the target and confirms every referenced file resolves in the dist).
Per the project's de-duplication convention, the field-by-field shape is NOT
re-enumerated here; refer to those smoke tests and to the "Manifest contract"
note in the manual test plan.
Internationalisation (i18n)
Reveal URLs is localised into ten languages besides English (Danish, German,
Spanish, Finnish, French, Italian, Dutch, Norwegian, Polish, Swedish). The set is
single-sourced in packages/core/src/locales.json (SUPPORTED_LOCALES, native
names and the default), which both the extension bundles (via the resolved JSON
import) and the plain-Node website build read. Every non-English string is
machine-translated and pending human review; the provenance marker is per format
(the _locales use each message's description, the website chrome dictionaries
record it in site/i18n/README.md, and translated doc sources carry a first-line
HTML comment).
There are three independent localisation surfaces, deliberately not cross-wired, and each is split along a build-time / runtime line.
- The options page (AD-1). It ships STATIC English markup; every translatable
node carries a
data-i18n="<key>"(text) ordata-i18n-<attr>="<key>"(attribute) annotation. At runtimepackages/webext/src/options/locale.tsis the apply-layer: itfetches the page's own packaged_locales/<code>/messages.json(viaruntime.getURL, noweb_accessible_resourcesneeded), rewrites each annotated node bytextContentONLY (so the form's<code>example hints stay intact), and sets<html lang>. The page is FULLY localised: alongside the static prose, the JS-built site-row labels and Remove button carry adata-i18nkey (with an initialtranslated text) so the same apply-layer re-localises rendered rows with no re-render, and the TRULY dynamic strings — the status line, the add-site feedback and the version line — render throughlocale.ts'stranslate(<key>)(currentMessages → English fallback → key) at call time. The English fallback is the_locales/en/messages.jsoncatalogue IMPORTED (bundled) intooptions.jsat build time, sotranslateresolves to readable English SYNCHRONOUSLY from module load — a dynamic string rendered before the switcher settles, or after a runtime catalogue fetch fails, never degrades to a raw message key.locale.tspublishes the applied message map and fires itsonLocaleChangelisteners after every apply, sooptions.tsre-renders the currently-shown status/feedback/version and re-points the Online manual link on each switch. The version line uses anoptionsVersionmessage whose{version}placeholder is substituted with the build number (token translated, number verbatim). The manual link follows the active locale to its doc variant (AD-9): English keeps the top-levelmanual.html, every other locale opens<code>/manual.html, preserving the?v=version stamp. The active locale is the user's saved choice or, failing that, the browser language resolved to a supported base language by core'sresolveLocale(AD-5); an unsupported or failed locale leaves the pre-rendered English (andtranslatethen degrades to the English fallback). The manual Display language switcher persists its choice under a dedicateduiLocalekey instorage.local— never in the syncedConfig, so it is per-device and never roams. - The website chrome (AD-2). The static-site generator
tooling/site.mjsserver-renders every chrome node in English while annotating itdata-i18n, and keeps<html lang="en-GB">(AD-4). The browser-side loadersite/scripts/i18n.mjs(a dependency-free ES module) then swaps the chrome into the visitor's language by precedence — a persistedlocalStoragechoice (revealUrlsSiteLocale) → the first supportednavigator.languagesbase match (AD-5) → English — fetchingsite/i18n/<code>.json. Unlike the options apply-layer it swaps viainnerHTML, because the site chrome values carry trusted inline markup (<code>,<a>); a parity test pins every non-English value's tag/attribute/URL sequence byte-identical to English so a translation can never drop a link or break a tag. A missing key keeps the pre-rendered English snapshot; a failed fetch leaves the English page intact (graceful degradation, never blank). - The documentation pages (AD-8/AD-9). Doc pages are rendered PER LOCALE at
build time, not runtime-swapped. English docs stay at the top-level paths; every
other locale gets a copy under a
<code>/folder, from a translateddocs/<code>/<DOC>.mdwhen present, else the English source — a per-page fallback thatbuildSiteLOGS rather than silently omitting. The doc BODY carries nodata-i18n(it is raw rendered markdown), so the runtime chrome loader never touches it; only the shared chrome nodes swap. The doc-page language switcher NAVIGATES to the sibling locale's page rather than swapping in place. - Internal-link localisation (Finding 2). The selected language carries across
doc navigation. Every INTERNAL doc anchor (the five pages
architecture.html,index.html,licence.html,manual.html,privacy.html) is taggeddata-doc-link="<page>"by the generator, and for the no-JavaScript case its href already resolves to the CURRENT page's own locale variant (navDocHref— the three per-locale doc pages localise; the English-onlyindex.html/licence.htmlstay top-level). The runtime loader'srewriteDocLinksthen re-targets every internal doc anchor — those tagged AND any catalogue-injected plain anchor such asmanual.html#installing— to the ACTIVE locale's variant, preserving any#hash/?queryand never touching external links. On a doc page, a STORED choice that differs from the page's own locale NAVIGATES to the matching variant; the loop guard fires only on an explicit stored choice (never a navigator-language preference alone) and only when the target differs from the page already shown. The catalogues themselves keep their English internal URLs, so the parity test stays green — the localisation is a pure runtime pass over the DOM.
Mail-client "Active sites" suppression (AD-3)
A mail-client target (Thunderbird today) already sees every rendered message, so
the per-host "Active sites" editor is redundant there and is removed. The decision
is carried by a documented per-target descriptor flag, mailClient: true, on
tooling/build.mjs's TARGETS.thunderbird — never a hardcoded target name in
build or UI logic — and is implemented in two halves:
- Build-time.
copyAssetskeys offdescriptorFor(target).mailClient: for a mail-client target it runs the pureremoveSitesSection(html)transform (which strips the whole<section class="sites">host editor, including every add-site control, and throwsMissingSourceErrorif the section is absent so a markup change can never silently ship the editor) and writes the result; browser targets receiveoptions.htmlbyte-for-byte viacpSync. - Runtime. The bundled controller
packages/webext/src/options/options.tstolerates the absent section:renderSites/readSites/applyConfig/readFormprobe#sites-listand no-op rather than throwOptionsFieldMissingError, andreadFormOMITS thesiteskey when the section is gone. Because omittingsiteswould letnormaliseConfigre-seed the built-in defaults and drop the user's sites,saveOptionsreinstates the storedsitesbefore normalising whenever the section is absent — that is what makes a mail-client save preserve the configured hosts. A runtime guard,guardMailClientSites, removes the section if it is still present, detecting a mail client by API/feature presence (messages/messageDisplay/scripting.messageDisplayon the injectedbrowser), never by target name; it runs first ininitOptionsso the later add/list wiring naturally skips the absent elements. In a browser runtime the guard is a no-op.
Security & privacy posture
- No network or exfiltration. No module performs
fetch/XHRor any other network call; the extension reads only the stored config and the page DOM. - Safe-DOM only. Annotation and the injected stylesheet use
createElement/textContent/typedstyle.*properties exclusively, neverinnerHTML/insertAdjacentHTML/eval. For the core this is enforced bypackages/core/test/purity.test.ts. - All external/config input normalised. Every read and write funnels through
normaliseConfig; colours passisSafeColour; match patterns passMATCH_PATTERN; content-root selectors are bounded byCONTENT_ROOT_PATTERNand only ever handed toquerySelectorAll/closest, never interpolated into markup or CSS. - Least privilege. Built-in webmail origins are fixed
host_permissions; additional hosts are opt-in viaoptional_host_permissionsand a gesture-boundpermissions.request. The toolbar action declares no popup. - Gecko data collection. The Firefox and Thunderbird manifests declare
browser_specific_settings.gecko.data_collection_permissions.required: ["none"]. - Licence. The project is AGPL-3.0-only (
LICENSE,package.json).
Testing
- Vitest unit tests cover both packages:
packages/core/test/*(config, contrast, host mismatch, link processor, styles, and the purity guard) andpackages/webext/test/*(content, storage, options, registration, toolbar, message display and a custom-site end-to-end test). Each package runsvitest run. - Cucumber BDD scenarios live in
features/(wired bycucumber.json) and drive the real@reveal-urls/webextmodules through faithful test doubles infeatures/support/webext.ts— notablynormaliseReadBack, which mirrors the engine OMITTING false-valued flags on read-back so a lenient harness cannot hide a re-registration loop — over a jsdom document set up infeatures/support/world.ts. - Per-target manifest smoke tests (
extensions/<engine>/test/manifest.smoke.test.mjs, Node's built-innode:test) assert each manifest's exact shape and that the built dist resolves every referenced file.tooling/test/adds build, version and definition-of-done tests. - CI (
.github/workflows/ci.yml) gates every push and pull request onmake test,make bddandmake lint(run directly on the runner, overriding the Makefile's DockerRUN/IMAGE_DEP). Version-stamping and packaging are deliberately excluded; they live in the separate, tag-triggered.github/workflows/release.yml.