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, Safari and Thunderbird — from one shared, pure code core.
Safari uses static scripts only for built-in providers. For a user-added host its
background injects the bundled content script after navigation, only when that
host's optional permission is granted. The extension reveals each link's
destination URL 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; the manifest
declares web/Windows/Mac/iOS/Android and Outlook.com form factors, and it is
published on Microsoft Marketplace for Outlook on the web and the desktop app, with
Outlook mobile pending Microsoft's Outlook Mobile design review) and a Gmail
Add-on (Apps Script CardService, published for the Gmail web/Android/iOS apps).
Safari is published on the App Store. 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).
That native findings path deliberately remains http:/https:-only.
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/destination.ts— scheme-aware destination mismatch classification and comparison.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 + global badge), tabReport.ts
(the content→background per-frame tally message contract), tabBadge.ts (the
per-tab mismatch/link-count toolbar badge, backed by storage.session),
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,
Safari and Thunderbird additionally ship a src/ of entry points; Edge and
Opera 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, registerToolbarToggle and registerTabBadge
(the last showing the per-tab mismatch/link-count badge from the content scripts'
reports). Firefox's
background entry is identical to Chrome's; Safari's content entry passes a
Safari-only frame eligibility predicate to the shared controller. Its static
provider entries retain match-about-blank, with Proton using all frames. For
user-added hosts, the background executes the content script after a completed
navigation only for an enabled, non-builtin rule covered by a granted optional
origin; its target uses that rule's allFrames value. This avoids Safari's
unreliable dynamic content-script registration without requesting all-sites
access. 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 it processes added Elements only when
they are strictly inside an already-matched root. Late root discovery is owned
by the bounded per-batch discoverMatchedRoots() scan (one scoped
querySelectorAll(contentRoot) per batch, gated by a WeakSet so each root is
processed once), while pendingShadowHosts cheaply re-checks previously plain
hosts until an open shadow root appears or the host disconnects. So in-root
churn, late-mounting sibling message bodies, and late shadow upgrades are all
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 delegates toresolveAnnotatableHref. It returnsnull(skip) only for an empty/in-page or unparseable href, resolves relatives againstbaseURI, and accepts every parseable scheme, includingmailto:,tel:,sms:and opaquejavascript:,data:andblob:URLs.ignoreHostsapplies only to host-bearinghttp:/https:links. - 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 the scheme-aware
destinationMismatchon the clean anchor text:classifySchemedispatches tohostMismatchfor web links,mailMismatchfor mail,phoneMismatchfor phone, andopaqueMismatchotherwise. It then callsannotateAnchor;showOnlyOnMismatchsuppresses only a non-mismatching link's visible inline reveal, while retaining its destinationtitle.
annotateAnchor renders SAFE-DOM only. It always saves the original title
(TitleSave) and writes the destination title — the only write to the anchor.
There is no separate title mode or warning badge.
Unless showOnlyOnMismatch hides a non-mismatching link, it prepends a
<span class="reveal-urls-url"> and a trailing <br>, leaving the link's own
children untouched. needsLeadingInlineRevealBreak adds a leading <br> when
visible inline content precedes the link on the same line. The span displays an
authored absolute href verbatim, or a relative href resolved against baseURI,
via inert textContent (prefixed with an arrow and a U+00A0 non-breaking space,
then truncated by truncateUrl); once connected, it gets a contrast backdrop
via applyContrastBackdrop. For an emphasised mismatch,
REVEAL_URLS_MISMATCH_CLASS is added to REVEAL_URLS_URL_CLASS, applying the
configured mismatch colour through applyColour plus bold by default in
REVEAL_URLS_CSS; an explicit font-weight override through
applyFontOverrides takes precedence. 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. 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 is the web branch of
destinationMismatch: it compares the link's visible text against its href by
registrable domain, not raw hostname, so a non-mismatching 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.
For mail links, mailMismatch compares all recipient registrable domains,
including to, cc and bcc; phoneMismatch compares resolvable dialled
numbers. opaqueMismatch always flags SCRIPT_LIKE_SCHEMES — javascript:,
data: and vbscript: — while other opaque schemes are flagged only when the
visible text names a concrete web, email or phone destination.
Styles and contrast
packages/core/src/styles.ts holds the injectable REVEAL_URLS_CSS (fixed layout,
weight, shape and a fixed white, fully opaque chip background — background: #ffffff,
opacity: 1; colours are NEVER interpolated into it) and the runtime
applyColour/applyFontOverrides/applyContrastBackdrop helpers.
applyContrastBackdrop reads the element's resolved colour and the first opaque
background found walking from the element ITSELF upward (its own background — the
chip's fixed white default or a host page's more specific override — before any
ancestor) 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, a
curated/user-facing name, and a builtin flag. The shipped DEFAULT_SITES
cover FastMail, Gmail, Outlook Live, Outlook 365, Proton (with allFrames),
Tuta, Yahoo and Zoho. 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, name 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 rule 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, 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 and falls back to
browser.storage.local (e.g. on Thunderbird); configAreaName reports which
is active. Chromium and Firefox sync settings through their supported sync
services. Safari exposes storage.sync, so it remains the selected area, but
its settings are device-local and do not roam between devices. 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, Safari
and Thunderbird ship their own src/; Edge and Opera declare chrome as their
source; the Outlook (Office.js) add-in is its own source. ACTIVE_TARGETS
(Chrome, Edge, Firefox, Opera, Safari and Thunderbird) is what
--all/--package build; Outlook builds 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.
Safari's dist/safari bundle is structurally validated by its manifest smoke
test. It is deliberately not passed to web-ext lint: that validator is
Firefox-oriented and rejects Safari's valid MV3 service worker and lack of a
Gecko extension ID. On macOS, tooling/safari-xcode.sh invokes
xcrun safari-web-extension-converter with the built directory, scaffolds an
ignored Xcode project, and archives it with explicit signing settings. After
scaffolding it stamps native configuration onto the generated project so a forced
re-scaffold is self-provisioning: the signing team, the App Store category
(LSApplicationCategoryType), the exempt-encryption declaration
(ITSAppUsesNonExemptEncryption = NO), the marketing version and build number
from the built manifest, and the app icons (filling the shared
AppIcon.appiconset from the committed assets/appicon PNGs while preserving
the converter's own Contents.json). The encryption declaration covers the app
and linked libraries, which use only export-exempt platform HTTPS; reassess it
before adding a native dependency that introduces encryption. Apple's converter
warnings must be reviewed before distribution through the Apple Developer
Program and App Store.
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, and into the committed
macOS/iOS Safari App Store icon set under assets/appicon that the Xcode scaffold
provisions, trying cairosvg, then inkscape, then ImageMagick. The macOS AppIcon
artwork is centred inside the classic safe-area grid with transparent padding,
while the opaque iOS marketing icon remains full-bleed.
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 many languages besides English. 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 selected-site dropdown/editor labels and Remove button carrydata-i18nkeys (with initialtranslated text) so the same apply-layer re-localises the rendered editor 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 its localised filename under<code>/(e.g.nl/handleiding.html, translated via core'slocalisedSitePage— the same shared map the website build emits the file under), 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. On the landing page it also reskins the localised phishing screenshot (imagesrc, intrinsicheightand the click-to-zoom anchorhref) to the locale actually applied, so a failed fetch keeps the English screenshot beneath the English chrome. 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, under a filename TRANSLATED into the locale (nl/handleiding.html,de/datenschutz.html) from the sharedpackages/core/src/site-page-names.jsonmap (the home page staysindex.html), from a translateddocs/<code>/<DOC>.mdwhen present, else the English source — a per-page fallback thatbuildSiteLOGS rather than silently omitting. Because the earlier model published these under their English names, the build also emits a thinnoindexredirect at every renamed old path (nl/manual.html→nl/handleiding.html) so an old bookmark, a search result or an already-installed older extension's manual link still resolves. 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 six pages
architecture.html,faq.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, under its translated filename (navDocHref— every internal page now has a per-locale variant; only the home page keepsindex.htmlin every locale). 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. Thedata-doc-linktag always carries the CANONICAL English page name; the resolved href uses the locale's TRANSLATED filename (AD-9,nl/handleiding.html), so the loader maps a localised filename back to its canonical page to stay idempotent. 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#site-selectand 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 dropdown/editor 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.