MadVikingGod/dash-randomizer

Why do we want all dashes to look the same. Let's give our websites a bit of flare.

★ 0Forks 0JavaScriptGitHub ↗Compare

README

Dash Randomizer

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.

What it does

  • 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>, anything contenteditable, and any subtree marked with data-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.

Popup

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.

Install for development

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.

Build

npm run build

Writes, for each target, an unpacked directory and a store-ready zip:

  • dist/chrome/ + dash-randomizer-chrome.zip — browser_specific_settings stripped, 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.

Test

npm install
npm test          # jsdom unit tests
npm run test:e2e  # loads the built extension into a real Chromium

npm 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

CI

.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.

Releasing

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

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-only

That 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

icons/*.png are generated, not hand-drawn. To regenerate them:

python3 scripts/generate_icons.py

Notes on the implementation

The 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.

Contributors

claude

Issues