A browser extension that takes every dash in a page's text and swaps it for a
random pick from the hyphen (-), the en dash (–), and the em dash (—).
One codebase, Manifest V3, runs on Chrome, Firefox, and Safari.
- Rewrites the text of every page you visit, including frames.
- Catches content that shows up later — infinite scroll, single-page app navigation, anything a script injects.
- Normalizes the wider dash family on the way in (
‐ ‑ ‒ – — ― −), so the output is always one of the three above. - Leaves alone:
<script>,<style>,<noscript>,<textarea>,<title>,<template>, anythingcontenteditable, and any subtree marked withdata-dash-randomizer-skip. - Leaves
<code>,<pre>,<kbd>,<samp>, and<var>alone too, unless you turn that option off in the popup.
Only text nodes are touched, so URLs in href attributes, form values, and
anything else the page sends back to a server are untouched. Turning the
extension off restores the original text in every open tab.
| Control | Effect |
|---|---|
| Randomize dashes | Master on/off. Off restores the original text everywhere. |
| Leave code and preformatted text alone | On by default, so command lines and code samples stay copy-pasteable. |
| Re-roll every dash | Draws a fresh random dash for every dash on every open page. |
Settings live in storage.local and content scripts react to storage changes,
which is why the extension needs no tabs or host permissions beyond the
content script match itself.
Chrome — go to chrome://extensions, turn on Developer mode, click
"Load unpacked", and select this directory.
Firefox — go to about:debugging#/runtime/this-firefox, click
"Load Temporary Add-on", and select manifest.json. Temporary add-ons are
removed when Firefox restarts.
Safari — see Safari below; it needs a conversion step and Xcode.
npm run buildWrites, for each target, an unpacked directory and a store-ready zip:
dist/chrome/+dash-randomizer-chrome.zip—browser_specific_settingsstripped, since Chrome flags it as an unrecognized key.dist/firefox/+dash-randomizer-firefox.zip— the manifest as-is.
The extension ships with no runtime dependencies — the packages contain nothing but the files in this repo.
npm install
npm test # jsdom unit tests
npm run test:e2e # loads the built extension into a real Chromiumnpm test runs content.js inside a jsdom page against a fake extension
storage API, covering the replacement itself, the skip rules, dynamically added
content, the guard against re-randomizing its own output, and the
restore-on-disable path.
npm run test:e2e builds the Chrome package, loads it into Chromium with
Playwright, and drives the real popup: re-roll, both checkboxes, and the
restore-on-disable path. Set CHROMIUM_PATH to reuse a Chromium already on
disk instead of Playwright's.
Other checks:
npm run lint # ESLint
npm run format:check # Prettier
npm run lint:firefox # web-ext lint, the validator AMO runs at review time.github/workflows/ci.yml runs on every pull request and on pushes to main:
| Job | What it covers |
|---|---|
| Lint and formatting | ESLint + Prettier |
| Unit tests | jsdom suite, Node 22 |
| Chrome | builds, then loads the extension in Chromium and drives the popup |
| Firefox | builds, then web-ext lint |
| Safari | converts with Xcode's converter and compiles the result |
The Safari job runs on a macos-14 runner, which GitHub bills at 10x the
Linux rate. It is the only way to check Safari at all — the converter ships
inside Xcode — but if minutes get tight, restricting it to a paths filter or
to release tags is the obvious lever.
Releases are cut from a tag:
# bump the version in BOTH manifest.json and package.json first
git tag v1.1.0 && git push origin v1.1.0.github/workflows/release.yml then verifies the tag matches the manifest
version, re-runs lint and tests, builds all three targets, and opens a draft
GitHub release with the artifacts attached. Review it, then publish.
workflow_dispatch runs the same thing against a tag you name, for rehearsal.
Store submission is manual and deliberately so — every store wants a human to confirm the listing:
| Artifact | Where it goes |
|---|---|
dash-randomizer-chrome.zip |
Chrome Web Store developer dashboard |
dash-randomizer-firefox.zip |
addons.mozilla.org |
dash-randomizer-safari-xcode.zip |
Open in Xcode, set your team, archive, submit |
To automate the Firefox upload later, web-ext sign with AMO_JWT_ISSUER and
AMO_JWT_SECRET secrets will produce a signed .xpi for self-hosting.
Safari supports Manifest V3 web extensions, and this extension's code is compatible with it — but Safari cannot load a plain zip. Every Safari extension must be wrapped in a native app bundle:
xcrun safari-web-extension-converter dist/chrome \
--app-name "Dash Randomizer" --macos-onlyThat generates an Xcode project which you build, sign, and ship through the App Store. It requires macOS, Xcode, and — for distribution to anyone else — a paid Apple Developer Program membership plus notarization. CI builds the project unsigned to prove it compiles; it cannot produce an installable Safari extension without those credentials.
icons/*.png are generated, not hand-drawn. To regenerate them:
python3 scripts/generate_icons.pyThe one genuinely tricky part is that the extension rewrites text while a
MutationObserver watches for text changes. Writing a dash triggers the
observer, which would re-roll the dash, which triggers the observer again.
content.js keeps a WeakMap of the exact string it last wrote to each text
node and skips any node whose current text matches — its own writes are
recognized and ignored, while genuine page updates still get processed. A
second WeakMap holds each node's original text, which is what makes both
"re-roll" and "restore on disable" work from the pristine text rather than
compounding on previous rewrites.