├── .github └── workflows │ └── lint.yml ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── PRIVACY_POLICY.md ├── README.md ├── assets └── .gitkeep ├── bun.lock ├── components ├── InfoIcon.svelte ├── StealthMode.svelte ├── UnsavedChangesToast.svelte └── WarningIcon.svelte ├── entrypoints ├── background │ ├── index.ts │ └── utils.ts ├── content │ ├── index.ts │ └── utils.ts ├── options │ ├── App.svelte │ ├── components │ │ ├── FaqModal.svelte │ │ ├── NameMappings.svelte │ │ └── UpgradeModal.svelte │ ├── constants.ts │ ├── index.html │ ├── main.ts │ └── utils.ts └── popup │ ├── App.svelte │ ├── index.html │ ├── main.ts │ └── style.css ├── eslint.config.js ├── package.json ├── public └── icon │ ├── nb128.png │ ├── nb16.png │ ├── nb32.png │ ├── nb48.png │ ├── stealth.png │ ├── trans128.png │ ├── trans16.png │ ├── trans32.png │ └── trans48.png ├── services ├── configService.ts ├── domObserver.ts └── textProcessor.ts ├── tsconfig.json ├── uno.config.ts ├── utils ├── __tests__ │ └── validations.test.ts ├── constants.ts ├── index.ts ├── migrations.ts ├── types.ts └── validations.ts ├── vitest.config.ts └── wxt.config.ts /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: Setup Bun 19 | uses: oven-sh/setup-bun@v2 20 | 21 | - name: Install dependencies 22 | run: bun install 23 | 24 | - name: ESLint 25 | uses: rkuykendall/lint-action@master 26 | with: 27 | eslint: true 28 | eslint_extensions: "js,ts,svelte" 29 | 30 | - name: Svelte Check 31 | uses: ghostdevv/svelte-check-action@v1 32 | if: github.event_name == 'pull_request' 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .output 12 | stats.html 13 | stats-*.json 14 | .wxt 15 | web-ext.config.ts 16 | 17 | # Editor directories and files 18 | .vscode/* 19 | !.vscode/extensions.json 20 | !.vscode/settings.json 21 | .idea 22 | .DS_Store 23 | *.suo 24 | *.ntvs* 25 | *.njsproj 26 | *.sln 27 | *.sw? 28 | 29 | # Environment variables 30 | .env.submit* 31 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "svelte.svelte-vscode", 4 | "dbaeumer.vscode-eslint", 5 | "unocss.unocss", 6 | "vitest.explorer" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.format.enable": true, 3 | "svelte.enable-ts-plugin": true, 4 | "[svelte]": { 5 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 6 | }, 7 | "cSpell.words": ["deadname", "deadnames", "tseslint", "valibot"], 8 | "eslint.validate": [ 9 | "javascript", 10 | "typescript", 11 | "svelte" 12 | ], 13 | "typescript.tsdk": "node_modules/typescript/lib" 14 | } 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v2.1.0 4 | 5 | - Added keyboard shortcut functionality for quickly enabling/disabling the extension 6 | - Added setting to replace plain text email addresses (Note: Email hyperlinks remain unchanged and will use the original address when clicked) 7 | - Added setting to hide debug logs that contain name mapping information (including deadnames) (thanks [@Lenochxd](https://github.com/Lenochxd)!) 8 | - Resolved UI issue causing switches to not show an indicator when focused 9 | - Excluded SVGs from processing to improve performance 10 | 11 | ## v2.0.4 12 | 13 | - Fix issue with duplicate names being allowed in settings due to case sensitive matching instead of case insensitive. 14 | 15 | ## v2.0.3 16 | 17 | - Resolves bug with names not replacing while maintaining performance. 18 | 19 | ## v2.0.2 20 | 21 | - Resolved issue with excessive processing on added DOM nodes, causing slow performance on larger pages. 22 | 23 | ## v2.0.1 24 | 25 | - Resolves bug with recursive name replacement causing browser lag and crashes. 26 | 27 | ## v2.0.0 🎉 28 | 29 | ### Added or Changed 30 | 31 | - Complete rewrite of the extension using modern technologies/patterns: 32 | - Improved performance via new name replacement mechanism 33 | - Simplified replacement logic via cleaner browser-built in functions (such as document.createNodeIterator) 34 | - Migration to WXT extension framework for easier development and maintenance 35 | - Removed need for complicated build and task systems 36 | - Rewrote UI to be more modern, user friendly, and accessible 37 | - Integrated mutation observer to detect changes to the DOM and update the UI accordingly 38 | - Re-analyzed which fields are processed to better follow user expectations 39 | - Ensured full feature parity with the previous version (except for TamperMonkey scripts) 40 | - New features: 41 | - Ability to add infinite deadnames and chosen names to process 42 | - Theme support with trans pride and non-binary pride gradient options 43 | - Name highlighting with pride-colored gradients 44 | - Syncing settings across devices is now optional in case of privacy concerns 45 | - Content blocking to prevent flashing of deadnames 46 | - Support for shadow DOM elements, meaning more websites are supported 47 | - Metrics to track the performance of the extension, implement easier debugging 48 | - Improved accessibility: 49 | - Additional support for screen readers by processing ARIA attributes, `alt` tags 50 | - High contrast theme option 51 | - Improved keyboard navigation and accessibility in settings pages 52 | - Enhanced developer experience: 53 | - Focus on leveraging WXT features and packages to improve development speed and quality 54 | - Less code to maintain 55 | - Better codebase structure to reduce barriers to entry for new contributors 56 | - Svelte for UI components to speed up development, allow more flexibility in UI development 57 | - OnuUI and UnoCSS for styling; modern, consistent, and easy to use 58 | - Valibot for data validation and type safety 59 | - Scripts for linting, type checking, building, etc. 60 | - Misc: 61 | - Added this changelog :) 62 | - More detailed and improved communication in Privacy Policy 63 | - Recreated README to be accurate considering all the changes in this version 64 | 65 | ### Removed 66 | 67 | - Entire previous codebase to modernize and improve the extension 68 | - Support for TamperMonkey scripts 69 | - May be added back in the future if there is a need for it, though likely not used frequently and adds additional maintenance burden 70 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Deadname Remover 2 | 3 | Thank you for your interest in contributing to Deadname Remover! This document provides guidelines and instructions for setting up, developing, and contributing to the project. 4 | 5 | ## Non-Technical Contributions 6 | 7 | ### Bug Reports 8 | 9 | Bug reports are very appreciated and help improve the extension for everyone. Before submtting, please check the existing issues to avoid duplicates. When submitting a bug report, please include: 10 | 11 | - Browser and version 12 | - Extension version 13 | - Steps to reproduce 14 | - Expected vs actual behavior 15 | - Any relevant error messages 16 | - Links to websites where the bug is present if applicable 17 | - Screenshots or videos if applicable 18 | - If you plan on/would like to try to fix the bug 19 | - If so, please detail a technical analysis of the bug and potential solutions for approval 20 | 21 | ### Feature Requests 22 | 23 | Feature requests are very welcome! Before submitting, please check the existing issues to avoid duplicates. When submitting a feature request, please include: 24 | 25 | - A clear description of the feature and its use case 26 | - Explanation of how it benefits users 27 | - Consider potential implementation challenges 28 | - If you plan on/would like to try to implement the feature 29 | - If so, please detail a technical analysis of the feature and potential implementations for approval 30 | 31 | ## Technical Contributions, Setup, and Considerations 32 | 33 | ### Development Setup 34 | 35 | ### Prerequisites 36 | 37 | - Node.js (v20 or higher) 38 | - If using Windows, Node v23 will not work. Node v22 is recommended. See [#595](https://github.com/arimgibson/Deadname-Remover/issues/595) for more information. 39 | - Bun (v1.0.0 or higher) -- used as the package manager 40 | - Git 41 | - A Chromium-based browser (Chrome, Edge, Brave, etc.) or Firefox installed for running the WXT development browser 42 | 43 | #### Getting Started 44 | 45 | 1. Fork the repository on GitHub 46 | 2. Clone your fork locally: 47 | ```bash 48 | git clone https://github.com//deadname-remover.git 49 | cd deadname-remover 50 | ``` 51 | 3. Install dependencies: 52 | ```bash 53 | bun install 54 | ``` 55 | 4. Start the development server and development browser: 56 | - For Chromium-based browsers: 57 | ```bash 58 | 59 | bun run dev 60 | 61 | CHROME_PATH= bun run dev 62 | ``` 63 | - For Firefox: 64 | ```bash 65 | bun run dev:firefox 66 | ``` 67 | 68 | #### Development Commands 69 | 70 | - `bun run dev` - Start development server for Chromium browsers 71 | - `bun run dev:firefox` - Start development server for Firefox 72 | - `bun run build` - Build the extension for Chromium browsers 73 | - `bun run build:firefox` - Build the extension for Firefox 74 | - `bun run zip` - Create distribution zip for Chromium browsers 75 | - `bun run zip:firefox` - Create distribution zip for Firefox 76 | - `bun run lint` - Run ESLint 77 | - `bun run check` - Run type checking with TypeScript and Svelte 78 | 79 | ### Technology Stack 80 | 81 | - [WXT](https://github.com/WXT-Community/WXT) - Browser extension framework 82 | - [Svelte](https://svelte.dev/) - UI framework 83 | - [TypeScript](https://www.typescriptlang.org/) - Programming language 84 | - [Valibot](https://github.com/fabian-hiller/valibot) - Data validation 85 | - [UnoCSS](https://github.com/unocss/unocss) - CSS engine 86 | - [Onu UI](https://github.com/onu-ui/onu-ui) - UI component library 87 | - [Bun](https://bun.sh/) - Package manager 88 | 89 | ### Contributing Guidelines 90 | 91 | #### Code Style 92 | 93 | - Use TypeScript for all new code 94 | - Follow existing code formatting patterns 95 | - Use meaningful variable and function names 96 | - Add comments for complex logic 97 | - Keep functions small and focused 98 | - Write type definitions for new features 99 | - Maintain codebase structure by utilizing `utils` and `components` folders 100 | 101 | #### Pull Request Process 102 | 103 | Pull requests are the method used to contribute code to the project. Contributions from all experience levels are welcome; feel free to ask questions and get help if you would like to contribute and need some direction or assistance. Pull request reviews will always keep in mind varying skill levels and intend to serve as a learning experience and collaborative process. 104 | 105 | 1. Submit a new issue or tag @arimgibson on an existing issue indicating your intent to contribute. **To ensure your contribution is likely to be accepted, please wait for confirmation before beginning work on a pull request.** 106 | 2. Create a new branch for your feature or fix with a descriptive name: 107 | ```bash 108 | git checkout -b feature/ 109 | OR 110 | git checkout -b fix/ 111 | ``` 112 | 3. Make your changes and commit them with a descriptive commit message: 113 | ```bash 114 | git commit -m "" 115 | ``` 116 | 4. Push your changes to your fork: 117 | ```bash 118 | git push origin 119 | ``` 120 | 5. Test and understand your changes before submitting: 121 | - Understand the footprint of your changes and potential side effects 122 | - Verify that existing functionality isn't broken 123 | - Consider edge cases and potential performance impacts 124 | - Maintain alignment with extension's purpose and [Privacy Policy](./PRIVACY_POLICY.md) 125 | - Pass all checks (linting and type checking) 126 | - Updates documentation if needed (including the changelog) 127 | - Follows the codebase structure and style 128 | - Contains high quality code you can stand behind (ask for help if needed!) 129 | 6. Create a pull request from your fork to the main repository that: 130 | - Describes the changes made 131 | - Includes any relevant issue numbers 132 | - Contians videos or screenshots demonstrating the changes (if applicable) 133 | 7. Wait for review and feedback (be responsive to maintainer questions and feedback) 134 | 135 | ## Code of Conduct 136 | 137 | Overall, foster a welcoming and inclusive environment for all contributors and users. 138 | 139 | - Be respectful and inclusive 140 | - Keep discussions constructive and professional 141 | - Ask for help if you need it 142 | 143 | ## Questions or Need Help? 144 | 145 | If you have questions or need help with contributing: 146 | 147 | - Open a GitHub issue 148 | - Contact Ari Gibson at [hi@arigibson.com](mailto:hi@arigibson.com) 149 | 150 | ## License 151 | 152 | By contributing to Deadname Remover, you agree that your contributions will be licensed under the MIT License. See [LICENSE](./LICENSE) for details. 153 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Ari Gibson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /PRIVACY_POLICY.md: -------------------------------------------------------------------------------- 1 | Version 2.0.0 2 | 3 | # Personal Data 4 | 5 | Deadname Remover has never collected and will never collect any personal data, browsing history, etc. All data submitted by the user (settings) remains on the user's device, unless the setting "Sync Settings Across Devices" is opted into (see Storage Sync API section). All data processing (name replacement) happens locally on the user's device. 6 | 7 | Any questions regarding the extension's usage and privacy compliance can be directed to maintainer Ari Gibson at hi@arimgibson.com. 8 | 9 | # Third Party 10 | 11 | ## Storage Sync API (Subprocessors Vary) 12 | If the user opts in via settings (disabled by default), Deadname Remover can use Chrome (Chromium) or WebExtensions Storage Sync API for storing and synchronizing a user's settings across devices. This requires the user to have signed into their browser*. The specific storage of this data and privacy settings depend based on the user's browser (Chrome uses a Google account, Edge uses a Microsoft account, Firefox uses a Firefox account, etc.). 13 | 14 | When enabling sync, settings are moved from local storage to browser sync storage. When disabling sync, settings are moved to local storage but remain in sync storage until explicitly deleted. Users can remove their data from browser sync storage at any time using the "Delete Synced Data" button in settings, which will reset all synced devices to default settings. 15 | 16 | The extension stores only configuration data, which may include user-provided names for replacement. No browsing history, usage data, or other personal information is ever stored. 17 | 18 | Storage limits and retention policies are determined by the browser vendor's sync implementation. Users should refer to their browser's privacy policy for details about how synced extension data is handled. 19 | 20 | Defaults to opt out. This can be opted in and out via the settings. 21 | 22 | * This doesn't mean just signing into an account (e.g. Google account) in your browser. This is if your browser profile is connected via a cloud-synced account (e.g. a Google account for Chrome). Learn more here. 23 | 24 | ###### Updated: January 20, 2025 [(see update history)](https://github.com/arimgibson/Deadname-Remover/commits/main/PRIVACY_POLICY.md) 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Deadname Remover 🏳️‍⚧️ 2 | 3 | Deadname Remover is a browser extension that replaces deadnames with chosen names (primarily for transgender and non-binary people). By installing it, you can avoid the discomfort of seeing your or a friend/family member's deadname while browsing the web. 4 | 5 | - **Replaces deadnames**: Add infinite deadname and chosen name pairs to process and replace on most (if not all) websites 6 | - **Privacy-first**: All data processing locally on device. No data collection or browsing history stored. Settings remain local by default, with optional browser sync. See [Privacy Policy](./PRIVACY_POLICY.md) for more details 7 | - **Performant**: Extremely lightweight, no noticeable difference in page load times. Can run on low-end devices including Chromebooks 8 | - **Pride themes**: Add trans pride and non-binary pride gradient highlights on replaced names 9 | - **Syncing settings across devices**: Keep your settings in sync across all your devices (disabled by default for privacy reasons) 10 | - **Content blocking**: Prevent flashing of deadnames by blocking page content before the extension has finished processing (optional) 11 | - **Stealth mode**: Hide the extension icon to protect privacy and avoid being outed. Recommended to disable text highlighting as well. 12 | 13 | ## Installation & Usage 14 | 15 | Deadname Remover currently supports Chromium-based browsers, including Chrome, Edge, Brave, Arc, Opera, etc. and Firefox. Safari support hasn't been viable in the past, but will be explored in the future. 16 | 17 | ### Chrome Web Store (for any Chromium-based browser, including Chrome, Brave, Arc, Opera, etc.) 18 | 19 | 1. Visit the [Chrome Web Store page](https://chromewebstore.google.com/detail/deadname-remover/cceilgmnkeijahkehfcgfalepihfbcag) 20 | 2. Click "Add to Chrome" 21 | 3. Click "Add extension" when prompted 22 | 4. The extension icon will appear in your browser toolbar with a transgender flag icon 23 | 5. Click the icon to open the extension settings, then click "Open Full Settings" to add name pairs 24 | 25 | ### Firefox Add-ons 26 | 27 | 1. Visit the [Firefox Add-ons page](https://addons.mozilla.org/en-US/firefox/addon/deadname-remover/) 28 | 2. Click "Add to Firefox" 29 | 3. Click "Add" when prompted 30 | 4. The extension icon will appear in your browser toolbar with a transgender flag icon 31 | 5. Click the icon to open the extension settings, then click "Open Full Settings" to add name pairs 32 | 33 | ## Development & Contributing 34 | 35 | See [CONTRIBUTING.md](./CONTRIBUTING.md) for information on how to contribute to the project, including how to develop and build the extension. This README is mostly intended for users of the extension. 36 | 37 | ## License 38 | 39 | Deadname Remover is MIT licensed. See [LICENSE](./LICENSE) for more details. 40 | 41 | ## Privacy Policy 42 | 43 | See [PRIVACY_POLICY.md](./PRIVACY_POLICY.md) for more details. 44 | 45 | ## Contributors 46 | 47 | Thanks to all the contributors who have helped make Deadname Remover possible! 48 | 49 | 50 | 51 | 52 | 53 | ## Support the Project (Submit Bugs, Suggest Features) 54 | 55 | If you find Deadname Remover helpful, you can support its development by: 56 | - ⭐ Starring the repository on GitHub 57 | - 🐛 Reporting bugs or suggesting features through [GitHub Issues](https://github.com/arimgibson/deadname-remover/issues) (please search for existing issues before submitting a new one) 58 | - 💻 Contributing code or documentation improvements (see [CONTRIBUTING.md](./CONTRIBUTING.md)) 59 | - 📢 Spreading the word about the extension 60 | 61 | If you'd like to submit a bug or request a feature but don't want to sign up for GitHub, you can email me at [hi@arigibson.com](mailto:hi@arigibson.com). I'll add it in the GitHub Issues board for tracking and email you back the link to follow along. 62 | 63 | ## Roadmap 64 | 65 | See an updated project roadmap on this project's [GitHub Projects](https://github.com/arimgibson/Deadname-Remover/projects). At a high level, these are features that are planned for the extension: 66 | 1. Global and per-site blocklists 67 | 2. Publishing to the Edge Add-ons store 68 | 3. Custom regex patterns for advanced name replacement (including titles, suffixes, salutations, etc.) 69 | 70 | 71 | ## Contact 72 | 73 | For questions, comments, suggestions, or to get Deadname Remover approved for your school or organization (including DPAs), please contact Ari Gibson at [hi@arigibson.com](mailto:hi@arigibson.com). 74 | 75 | ## Acknowledgements 76 | 77 | - [@WillHayCode](https://github.com/willhaycode) for the original idea and implementation of Deadname Remover 78 | - [@Gusted](https://github.com/gusted) for a ton of meaningful contributions on top of the original implementation including performance improvements, code cleanup, and more 79 | 80 | ### Some Shoutouts for Underappreciated Tools Used by Deadname Remover 81 | 82 | - [@aklinker1](https://github.com/aklinker1) and WXT's community for the [WXT framework](https://github.com/WXT-Community/WXT) that Deadname Remover is built with 83 | - [@fabian-hiller](https://github.com/fabian-hiller) for [Valibot](https://github.com/fabian-hiller/valibot), a powerful, extensible, and extremely lightweight data validation library 84 | - [@zyyv](https://github.com/zyyv) for [Onu UI](https://github.com/onu-ui/onu-ui), a simple UI library built on top of UnoCSS 85 | - [@AsyncBanana](https://github.com/AsyncBanana) for [microdiff](https://github.com/AsyncBanana/microdiff), a tiny and fast diffing library 86 | - [@noppa](https://github.com/noppa) for [text-security](https://github.com/noppa/text-security), a cross-browser solution for hiding sensitive text 87 | - [@kbrgl](https://github.com/kbrgl) for [svelte-french-toast](https://github.com/kbrgl/svelte-french-toast), a port of the popular [react-hot-toast](https://github.com/timolins/react-hot-toast) library for Svelte 88 | 89 | -------------------------------------------------------------------------------- /assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arimgibson/Deadname-Remover/fdbec606f817cdc121df586ecfc75de9f4fda8c6/assets/.gitkeep -------------------------------------------------------------------------------- /components/InfoIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /components/StealthMode.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
14 |
15 |

16 | Content Filter 17 |

18 | 24 |
25 | 26 |

27 | Basic content filtering is active. Configure settings to customize filters. 28 |

29 |
30 | -------------------------------------------------------------------------------- /components/UnsavedChangesToast.svelte: -------------------------------------------------------------------------------- 1 | 28 | 29 | 30 | {toast.unsavedChanges} unsaved {toast.unsavedChanges === 1 31 | ? 'change' 32 | : 'changes'} 33 | 34 | 35 | -------------------------------------------------------------------------------- /components/WarningIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /entrypoints/background/index.ts: -------------------------------------------------------------------------------- 1 | import { browser } from 'wxt/browser' 2 | import { defineBackground } from '#imports' 3 | import { handleInstall, handleUpdate } from './utils' 4 | import { getConfig, updateExtensionAppearance } from '@/services/configService' 5 | 6 | export default defineBackground({ 7 | main: () => { 8 | // Check theming on extension load 9 | void (async () => { 10 | const config = await getConfig() 11 | await updateExtensionAppearance(config.stealthMode, config.theme) 12 | })() 13 | 14 | // Handle installation events 15 | browser.runtime.onInstalled.addListener((details) => { 16 | switch (details.reason) { 17 | case 'install': 18 | void handleInstall(details) 19 | break 20 | case 'update': 21 | void handleUpdate(details) 22 | break 23 | case 'chrome_update': 24 | default: 25 | break 26 | } 27 | }) 28 | }, 29 | }) 30 | -------------------------------------------------------------------------------- /entrypoints/background/utils.ts: -------------------------------------------------------------------------------- 1 | import { browser, Browser } from 'wxt/browser' 2 | import { 3 | checkAndMigrateSettings, 4 | createStealthUpgradeNotification, 5 | removeSelfMappings, 6 | removeRecursiveMappings, 7 | } from '@/utils/migrations' 8 | import { compare } from 'compare-versions' 9 | import { getConfig, setConfig } from '@/services/configService' 10 | import { debugLog, errorLog, filterEmptyNamePairs } from '@/utils' 11 | 12 | export async function handleInstall(_details: Browser.runtime.InstalledDetails) { 13 | await browser.tabs.create({ url: '/options.html?firstTime=true' }) 14 | } 15 | 16 | export async function handleUpdate(details: Browser.runtime.InstalledDetails) { 17 | const currentVersion = browser.runtime.getManifest().version 18 | await debugLog('current version', currentVersion) 19 | await debugLog('previous version', details.previousVersion) 20 | 21 | await checkAndMigrateSettings() 22 | 23 | let config = await getConfig() 24 | 25 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 26 | const needsEmailUpdate = !config.names.email || config.names.email.length === 0 27 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 28 | const needsDebugInfoUpdate = config.hideDebugInfo === undefined 29 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 30 | const needsToggleKeybindingUpdate = config.toggleKeybinding === undefined 31 | 32 | if (needsEmailUpdate || needsDebugInfoUpdate || needsToggleKeybindingUpdate) { 33 | if (needsEmailUpdate) { 34 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 35 | config.names.email = config.names.email ?? [] 36 | } 37 | if (needsDebugInfoUpdate) { 38 | config.hideDebugInfo = false 39 | } 40 | if (needsToggleKeybindingUpdate) { 41 | config.toggleKeybinding = null 42 | } 43 | try { 44 | await setConfig(config) 45 | } 46 | catch (error) { 47 | errorLog('error updating config settings', error) 48 | } 49 | } 50 | 51 | // Migrate empty name pairs 52 | try { 53 | const cleanedNames = filterEmptyNamePairs(config.names) 54 | if (JSON.stringify(cleanedNames) !== JSON.stringify(config.names)) { 55 | await debugLog('removing empty name pairs') 56 | await setConfig(config) 57 | await debugLog('empty name pairs removed successfully') 58 | } 59 | } 60 | catch (error) { 61 | errorLog('error removing empty name pairs', error) 62 | } 63 | 64 | // If last version is less than 2.0.0, open the options page with the upgrade flag 65 | if (details.previousVersion && compare(details.previousVersion, '2.0.0', '<')) { 66 | // If stealth mode is disabled, open the options page with the upgrade flag 67 | if (!config.stealthMode) { 68 | await browser.tabs.create({ url: `/options.html?upgrade=v2.0.0` }) 69 | return 70 | } 71 | 72 | // If stealth mode is enabled, show a notification 73 | await createStealthUpgradeNotification('2.0.0') 74 | } 75 | else if (details.previousVersion === '2.0.0' && currentVersion === '2.0.1') { 76 | await debugLog('migrating settings from v2.0.0 to v2.0.1') 77 | try { 78 | await debugLog('removing self mappings') 79 | config = removeSelfMappings(config) 80 | await debugLog('removing recursive mappings') 81 | config = removeRecursiveMappings(config) 82 | await debugLog('saving settings') 83 | await setConfig(config) 84 | } 85 | catch (error) { 86 | errorLog('error migrating settings from v2.0.0 to v2.0.1', error) 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /entrypoints/content/index.ts: -------------------------------------------------------------------------------- 1 | import { defineContentScript } from '#imports' 2 | import { getConfig, setupConfigListener } from '@/services/configService' 3 | import { DOMObserver } from '@/services/domObserver' 4 | import { TextProcessor } from '@/services/textProcessor' 5 | import type { Names, UserSettings } from '@/utils/types' 6 | import { 7 | blockContent, 8 | unblockContent, 9 | waitUntilDOMReady, 10 | createReplacementsMap, 11 | setStyle, 12 | } from './utils' 13 | import { debugLog, haveNamesChanged, registerKeyboardShortcut } from '@/utils' 14 | 15 | let currentObserver: DOMObserver | null = null 16 | let previousEnabled: boolean | undefined = undefined 17 | let previousNames: Names | undefined = undefined 18 | let previousTheme: UserSettings['theme'] | undefined = undefined 19 | let previousHighlight: boolean | undefined = undefined 20 | let toggleKeybindingListener: ((event: KeyboardEvent) => void) | null = null 21 | 22 | async function configureAndRunProcessor({ config }: { config: UserSettings }): Promise { 23 | // Only run disable logic if we're transitioning from enabled to disabled 24 | if (!config.enabled && previousEnabled) { 25 | if (currentObserver) { 26 | currentObserver.disconnect() 27 | currentObserver = null 28 | } 29 | 30 | // Revert all text replacements and remove theme 31 | TextProcessor.revertAllReplacements() 32 | document.querySelector('style[deadname]')?.remove() 33 | previousEnabled = false 34 | previousNames = undefined 35 | previousTheme = undefined 36 | return 37 | } 38 | 39 | // Skip if already disabled 40 | if (!config.enabled) { 41 | previousEnabled = false 42 | previousNames = undefined 43 | previousTheme = undefined 44 | return 45 | } 46 | 47 | // Check if names, theme, or highlightReplacedNames have changed 48 | const namesChanged = previousEnabled && haveNamesChanged(previousNames, config.names) 49 | const themeChanged = previousEnabled && previousTheme !== config.theme 50 | const highlightChanged = previousEnabled && previousHighlight !== config.highlightReplacedNames 51 | 52 | if (namesChanged) { 53 | await debugLog('names changed, reverting replacements to reapply with new names') 54 | TextProcessor.revertAllReplacements() 55 | } 56 | 57 | if (themeChanged) { 58 | await debugLog('theme changed, removing style to apply new theme') 59 | document.querySelector('style[deadname]')?.remove() 60 | } 61 | 62 | // Always update theme if it changed or on first enable 63 | if (!previousEnabled || themeChanged || highlightChanged) { 64 | setStyle({ 65 | document, 66 | theme: config.theme, 67 | highlight: config.highlightReplacedNames, 68 | }) 69 | } 70 | 71 | // Only proceed with setup if enabled and either it's the first run or names changed 72 | if (!previousEnabled || namesChanged) { 73 | // Disconnect previous observer to clean up and avoid memory leaks 74 | currentObserver?.disconnect() 75 | 76 | const textProcessor = new TextProcessor() 77 | const domObserver = new DOMObserver(textProcessor) 78 | currentObserver = domObserver 79 | 80 | const replacements = createReplacementsMap(config.names) 81 | 82 | await debugLog('replacements', replacements) 83 | if (config.blockContentBeforeDone) { 84 | await debugLog('blocking content') 85 | blockContent() 86 | } 87 | 88 | await waitUntilDOMReady() 89 | await debugLog('Initial document processing starting') 90 | 91 | // Await the full processing of the document body. 92 | await textProcessor.processDocument({ 93 | root: document.body, 94 | replacements, 95 | asyncProcessing: !config.blockContentBeforeDone, 96 | }) 97 | await debugLog('Initial document processing complete') 98 | 99 | if (config.blockContentBeforeDone) { 100 | await debugLog('unblocking content') 101 | unblockContent() 102 | } 103 | 104 | // Set up the observer for handling subsequent mutations (which do not block content). 105 | await debugLog('Setting up mutation observer') 106 | currentObserver.setup(replacements) 107 | await debugLog('Observer setup complete') 108 | } 109 | 110 | // Move these assignments to after all processing is complete 111 | previousEnabled = true 112 | previousNames = config.names 113 | previousTheme = config.theme 114 | previousHighlight = config.highlightReplacedNames 115 | } 116 | 117 | export default defineContentScript({ 118 | matches: [''], 119 | runAt: 'document_start', 120 | main: async () => { 121 | await debugLog('loaded') 122 | 123 | const config = await getConfig() 124 | await configureAndRunProcessor({ config }) 125 | toggleKeybindingListener = await registerKeyboardShortcut({ config, listener: toggleKeybindingListener }) 126 | 127 | // Handle configuration changes 128 | setupConfigListener((config) => { 129 | void (async () => { 130 | await configureAndRunProcessor({ config }) 131 | toggleKeybindingListener = await registerKeyboardShortcut({ config, listener: toggleKeybindingListener }) 132 | })() 133 | }) 134 | }, 135 | }) 136 | -------------------------------------------------------------------------------- /entrypoints/content/utils.ts: -------------------------------------------------------------------------------- 1 | import { createReplacementPattern } from '@/services/textProcessor' 2 | import { debugLog, kebabToCamel } from '@/utils' 3 | import type { Names, ReplacementsMap, NameEntry, UserSettings } from '@/utils/types' 4 | 5 | export async function waitUntilDOMReady() { 6 | if (document.readyState !== 'complete' && document.readyState !== 'interactive') { 7 | await debugLog('waiting for DOM to be ready') 8 | await new Promise((resolve) => { 9 | document.addEventListener('DOMContentLoaded', resolve, { once: true }) 10 | }) 11 | await debugLog('DOM is ready') 12 | } 13 | } 14 | 15 | export function blockContent() { 16 | if (document.getElementById('deadname-remover-blocker')) return 17 | 18 | // Despite what TypeScript thinks, sometimes these aren't available which causes 19 | // the extension to err out at this step and not replace names. 20 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 21 | document.documentElement?.classList.add('deadname-remover-not-ready') 22 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 23 | document.body?.classList.add('deadname-remover-not-ready') 24 | 25 | const style = document.createElement('style') 26 | style.id = 'deadname-remover-blocker' 27 | style.textContent = ` 28 | html.deadname-remover-not-ready, body.deadname-remover-not-ready { 29 | visibility: hidden !important; 30 | } 31 | ` 32 | document.head.appendChild(style) 33 | } 34 | 35 | export function unblockContent() { 36 | document.documentElement.classList.remove('deadname-remover-not-ready') 37 | document.body.classList.remove('deadname-remover-not-ready') 38 | document.getElementById('deadname-remover-blocker')?.remove() 39 | } 40 | 41 | export function createReplacementsMap(names: Names): ReplacementsMap { 42 | const replacements: ReplacementsMap = new Map() 43 | Object.values(names).forEach((nameArray: NameEntry[]) => { 44 | nameArray.forEach(({ mappings }) => { 45 | replacements.set(createReplacementPattern(mappings[0]), mappings[1]) 46 | }) 47 | }) 48 | return replacements 49 | } 50 | 51 | export function setStyle({ 52 | document, 53 | theme, 54 | highlight, 55 | }: { 56 | document: Document 57 | theme: UserSettings['theme'] 58 | highlight: boolean 59 | }): void { 60 | document.querySelector('style[deadname]')?.remove() 61 | 62 | const backgroundStyling = { 63 | 'non-binary': 'linear-gradient(90deg, rgb(255, 244, 48) 0%, white 25%, rgb(156, 89, 209) 50%, white 75%, rgb(255, 244, 48) 100%)', 64 | 'trans': 'linear-gradient(90deg, rgba(85,205,252) 0%, rgb(247,168,184) 25%, white 50%, rgb(247,168,184) 75%, rgb(85,205,252) 100%)', 65 | 'high-contrast': 'yellow', 66 | } as const 67 | 68 | const style: Element = document.createElement('style') 69 | style.setAttribute('deadname', '') 70 | document.head.appendChild(style) 71 | 72 | // Add CSS rules directly to the style element 73 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 74 | const sheet = (style as HTMLStyleElement).sheet! 75 | sheet.insertRule(` 76 | /* Styling for the Ari's Deadname Remover extension. Selection based on attribute to avoid styling conflicts based on class. */ 77 | mark[deadname] { 78 | background: ${highlight ? backgroundStyling[theme] : 'none'}; 79 | color: ${highlight ? 'black' : 'inherit'}; 80 | } 81 | `) 82 | } 83 | 84 | /** 85 | * Converts a kebab-cased attribute name into camelCase format, 86 | * prefixing it with 'deadname-' to create a data key. 87 | * 88 | * @param {string} attr - The attribute name in kebab-case. 89 | * @returns {string} The converted data key in camelCase. 90 | */ 91 | export function getDataKey(attr: string): string { 92 | return kebabToCamel(`deadname-${attr}`) 93 | } 94 | -------------------------------------------------------------------------------- /entrypoints/options/App.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 191 | 192 | 193 |
194 | {#if isLoading}{:else} 195 |
{ 198 | e.preventDefault() 199 | void handleSubmit() 200 | }} 201 | aria-label="Settings form" 202 | > 203 |

204 | Deadname Remover Settings 205 |
206 | 218 | {#if showFaqTooltip} 219 | 223 |
224 |
First time? 👋
225 | 233 |
234 |
Check out our FAQs before you start configuring your settings
235 |
236 | {/if} 237 |
238 |

239 | 240 | 241 |
242 |

246 | General Settings 247 |

248 |
253 | {#each generalSettingKeys as setting (setting.value)} 254 |
255 | 270 |

274 | {setting.description} 275 |

276 | {#if setting.value === 'syncSettingsAcrossDevices'} 277 | 306 | {/if} 307 |
308 | {/each} 309 | 310 |
311 |
312 | 315 |
316 | 326 | 329 |
330 |
331 |

332 | Changes the color of the highlight on replaced names. 333 |

334 |
335 | 336 |
337 | 338 |
339 |
captureShortcut = true} 346 | onkeydown={(e) => { 347 | if (captureShortcut) { 348 | e.preventDefault() 349 | 350 | // Skip if only modifier keys are pressed 351 | const isModifierKey = ['Control', 'Alt', 'Shift', 'Meta'].includes(e.key) 352 | if (!isModifierKey) { 353 | settings.toggleKeybinding = { 354 | key: e.key, 355 | alt: e.altKey, 356 | ctrl: e.ctrlKey, 357 | shift: e.shiftKey, 358 | meta: e.metaKey, 359 | } 360 | captureShortcut = false 361 | } 362 | } 363 | }} 364 | onblur={() => captureShortcut = false} 365 | > 366 | 367 | {captureShortcut 368 | ? 'Press keys...' 369 | : settings.toggleKeybinding 370 | ? formatKeyboardShortcut(settings.toggleKeybinding) 371 | : 'Disabled -- Click Here to Set'} 372 | 373 | 384 |
385 | 386 | 387 |
388 |

389 | Suggested shortcuts {platform === 'mac' ? '(for Mac)' : '(for Windows/Linux)'}: 390 |

391 |
392 | {#if platform === 'mac'} 393 | {#each [ 394 | { label: '⌥ Option+Q', shortcut: { key: 'q', alt: true, ctrl: false, shift: false, meta: false } }, 395 | { label: '⌥⇧ Option+Shift+Q', shortcut: { key: 'q', alt: true, ctrl: false, shift: true, meta: false } }, 396 | { label: '⌥ Option+W', shortcut: { key: 'w', alt: true, ctrl: false, shift: false, meta: false } }, 397 | { label: '⌘⌥ Command+Option+Z', shortcut: { key: 'z', alt: true, ctrl: false, shift: false, meta: true } }, 398 | ] as suggestion (suggestion.label)} 399 | 408 | {/each} 409 | {:else} 410 | {#each [ 411 | { label: 'Alt+Q', shortcut: { key: 'q', alt: true, ctrl: false, shift: false, meta: false } }, 412 | { label: 'Alt+Shift+Q', shortcut: { key: 'q', alt: true, ctrl: false, shift: true, meta: false } }, 413 | { label: 'Alt+W', shortcut: { key: 'w', alt: true, ctrl: false, shift: false, meta: false } }, 414 | { label: 'Ctrl+Alt+Z', shortcut: { key: 'z', alt: true, ctrl: true, shift: false, meta: false } }, 415 | ] as suggestion (suggestion.label)} 416 | 425 | {/each} 426 | {/if} 427 |
428 |
429 |
430 |

431 | Note: it is recommended to ensure the key combination used is not already in use by another browser feature or extension. 432 |
433 | Keyboard shortcut to quickly enable or disable the extension. Does not show any indication of being toggled for privacy purposes. Shortcut does not work on this page, please test on a different page after setting. 434 |

435 |
436 |
437 |
438 | 439 | 440 | hideDeadnames = !hideDeadnames} 444 | /> 445 | 446 | 447 | 450 | 451 | {/if} 452 |
453 | 454 | {#if showUpgradeModal} 455 | showUpgradeModal = false} 458 | /> 459 | {/if} 460 | 461 | {#if showFaqModal} 462 | showFaqModal = false} 464 | /> 465 | {/if} 466 | -------------------------------------------------------------------------------- /entrypoints/options/components/FaqModal.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /entrypoints/options/components/NameMappings.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 |
16 |
17 |

21 | Name Replacement 22 |

23 | 35 |
36 |
37 | {#each nameKeys as name (name.value)} 38 |
39 | {name.label} 42 |
43 |
44 | {#each settings.names[name.value] as _names, index (name.value + '-' + String(index))} 45 |
50 | { 62 | if (e.key === 'Enter') { 63 | e.preventDefault() 64 | settings.names[name.value].push({ 65 | mappings: ['', ''], 66 | }) 67 | // Wait for DOM update then focus new input 68 | setTimeout(() => { 69 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unnecessary-type-assertion 70 | ((document.querySelector(`#deadname-${name.value}-${String(settings.names[name.value].length - 1)}`)!) as HTMLInputElement).focus() 71 | }, 0) 72 | } 73 | }} 74 | oninput={(e) => { 75 | validateNameField({ 76 | target: (e as Event).target as HTMLInputElement, 77 | type: 'deadname', 78 | nameCategory: name.value, 79 | index, 80 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion 81 | names: $state.snapshot(settings.names) as Names, 82 | }) 83 | }} 84 | /> 85 | { 94 | if (e.key === 'Enter') { 95 | e.preventDefault() 96 | settings.names[name.value].push({ 97 | mappings: ['', ''], 98 | }) 99 | // Wait for DOM update then focus new input 100 | setTimeout(() => { 101 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unnecessary-type-assertion 102 | ((document.querySelector(`#properName-${name.value}-${String(settings.names[name.value].length - 1)}`)!) as HTMLInputElement).focus() 103 | }, 0) 104 | } 105 | }} 106 | oninput={(e) => { 107 | validateNameField({ 108 | target: (e as Event).target as HTMLInputElement, 109 | type: 'properName', 110 | nameCategory: name.value, 111 | index, 112 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion 113 | names: $state.snapshot(settings.names) as Names, 114 | }) 115 | }} 116 | /> 117 | 129 |

133 |
134 | {/each} 135 |
136 | 152 |
153 |
154 | {/each} 155 |
156 |
157 | 158 | 173 | -------------------------------------------------------------------------------- /entrypoints/options/components/UpgradeModal.svelte: -------------------------------------------------------------------------------- 1 | 25 | 26 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /entrypoints/options/constants.ts: -------------------------------------------------------------------------------- 1 | export const faqs = [ 2 | { 3 | question: 'How do I add multiple names?', 4 | answer: 'Click the "Add Name" button under each name type in the "Name Replacement" section to add as many names as you want. Each name should have a deadname and the proper name to replace it with.', 5 | }, 6 | { 7 | question: 'Why aren\'t names being replaced in text inputs, forms, or other editable content?', 8 | answer: 'To prevent accidentally outing users, the extension doesn\'t replace text in input fields, forms, or editable content. This prevents accidental submission of replaced names in emails, messages, or documents. If there\'s a place your name isn\'t replaced but you think it should be, please submit a bug (see below).', 9 | }, 10 | { 11 | question: 'What should I do if content shifts around when using the "Block Page Until Replacements Finished" feature?', 12 | answer: 'If you notice content jumping or shifting around the page when using the content blocking feature, please submit a bug (see below). Include the website URL and a description of what\'s happening to help someone fix it.', 13 | }, 14 | { 15 | question: 'Why am I still seeing my deadname flash on the page, even with the "Content Blocking" feature enabled?', 16 | answer: 'Due to how some websites render content, it\'s possible to see a deadname flash on the screen briefly, especially after the initial page load. The extension is built to replace these as soon as possible, so if it flashes for more than a few seconds or never updates, please submit a bug (see below).', 17 | }, 18 | { 19 | question: 'How do I report bugs or request features?', 20 | answer: 'You can submit bugs or feature requests through GitHub Issues or email. Visit github.com/arimgibson/deadname-remover/issues to create a new issue, or email hi@arigibson.com if you prefer not to use GitHub. I\'ll add it in the GitHub Issues board for tracking and email you back the link to follow along.', 21 | }, 22 | ] as const 23 | 24 | export const deadnameErrorMessages = { 25 | emptyDeadname: 'Deadname must not be empty', 26 | emptyProperName: 'Proper name must not be empty', 27 | duplicate: 'Deadname already exists', 28 | self: 'Cannot set deadname to proper name', 29 | recursive: 'Cannot set deadname to a name that has already been replaced', 30 | } as const 31 | 32 | export const emailErrorMessages = { 33 | emptyDeadname: 'Old email must not be empty', 34 | emptyProperName: 'New email must not be empty', 35 | duplicate: 'Email already exists', 36 | self: 'Cannot set old email to new email', 37 | recursive: 'Cannot set old email to an email that has already been replaced', 38 | invalidEmail: 'Invalid email provided', 39 | } as const 40 | -------------------------------------------------------------------------------- /entrypoints/options/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Deadname Remover Settings 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /entrypoints/options/main.ts: -------------------------------------------------------------------------------- 1 | import 'virtual:uno.css' 2 | import { mount } from 'svelte' 3 | import App from './App.svelte' 4 | 5 | const app = mount(App, { 6 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 7 | target: document.getElementById('app')!, 8 | }) 9 | 10 | export default app 11 | -------------------------------------------------------------------------------- /entrypoints/options/utils.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot' 2 | import { 3 | validateNoDuplicateDeadnames, 4 | validateNoRecursiveMappings, 5 | validateNoSelfMappings, 6 | } from '@/utils/validations' 7 | import { Names, trimmedEmail } from '@/utils/types' 8 | import { 9 | deadnameErrorMessages, 10 | emailErrorMessages, 11 | } from './constants' 12 | 13 | type SetFieldErrorInput = { 14 | target: HTMLInputElement 15 | type: 'deadname' | 'properName' 16 | nameCategory: keyof Omit 17 | index: number 18 | errorType: keyof typeof deadnameErrorMessages 19 | } | { 20 | target: HTMLInputElement 21 | type: 'deadname' | 'properName' 22 | nameCategory: keyof Pick 23 | index: number 24 | errorType: keyof typeof emailErrorMessages 25 | } 26 | 27 | export function validateNameField({ 28 | target, 29 | type, 30 | nameCategory, 31 | index, 32 | names, 33 | }: { 34 | target: HTMLInputElement 35 | type: 'deadname' | 'properName' 36 | nameCategory: keyof Names 37 | index: number 38 | names: Names 39 | }) { 40 | if (target.value.trim().length === 0) { 41 | setNameFieldError({ 42 | target, 43 | type, 44 | nameCategory, 45 | index, 46 | errorType: type === 'deadname' ? 'emptyDeadname' : 'emptyProperName', 47 | }) 48 | return 49 | } 50 | 51 | if (type === 'deadname') { 52 | const noDuplicates = validateNoDuplicateDeadnames(names) 53 | if (!noDuplicates) { 54 | setNameFieldError({ 55 | target, 56 | type, 57 | nameCategory, 58 | index, 59 | errorType: 'duplicate', 60 | }) 61 | return 62 | } 63 | } 64 | 65 | const noSelfMappings = validateNoSelfMappings(names) 66 | if (!noSelfMappings) { 67 | setNameFieldError({ 68 | target, 69 | type, 70 | nameCategory, 71 | index, 72 | errorType: 'self', 73 | }) 74 | return 75 | } 76 | 77 | const noRecursiveMappings = validateNoRecursiveMappings(names) 78 | if (!noRecursiveMappings) { 79 | setNameFieldError({ 80 | target, 81 | type, 82 | nameCategory, 83 | index, 84 | errorType: 'recursive', 85 | }) 86 | return 87 | } 88 | 89 | if (nameCategory === 'email') { 90 | const isValidEmail = v.safeParse(trimmedEmail, target.value) 91 | if (!isValidEmail.success) { 92 | setNameFieldError({ 93 | target, 94 | type, 95 | nameCategory, 96 | index, 97 | errorType: 'invalidEmail', 98 | }) 99 | return 100 | } 101 | } 102 | 103 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 104 | const errorField: HTMLParagraphElement = document.querySelector(`#nameField-error-${nameCategory}-${String(index)}`)! 105 | if (errorField.dataset.errorType === 'self' || errorField.dataset.nameType === type) { 106 | target.ariaInvalid = 'false' 107 | errorField.dataset.nameType = '' 108 | errorField.dataset.errorType = '' 109 | errorField.textContent = '' 110 | target.removeAttribute('aria-describedby') 111 | } 112 | } 113 | 114 | function setNameFieldError({ 115 | target, 116 | type, 117 | nameCategory, 118 | index, 119 | errorType, 120 | }: SetFieldErrorInput) { 121 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 122 | const errorField: HTMLParagraphElement = document.querySelector(`#nameField-error-${nameCategory}-${String(index)}`)! 123 | target.ariaInvalid = 'true' 124 | errorField.dataset.nameType = type 125 | errorField.dataset.errorType = errorType 126 | const content = nameCategory === 'email' ? emailErrorMessages[errorType] : deadnameErrorMessages[errorType] 127 | errorField.textContent = content 128 | target.setAttribute('aria-describedby', `nameField-error-${nameCategory}-${String(index)}`) 129 | } 130 | -------------------------------------------------------------------------------- /entrypoints/popup/App.svelte: -------------------------------------------------------------------------------- 1 | 125 | 126 | 127 |
128 | {#if isLoading}{:else} 129 | {#if settings.stealthMode} 130 | 131 | {:else} 132 |
135 |

136 | Deadname Remover Settings 137 |

138 | 139 | 140 |
141 |
142 | {#each generalSettingKeys as setting (setting.value)} 143 | 160 | {/each} 161 | 162 |
163 | 166 |
167 | 178 | 181 |
182 |
183 | 184 |
185 | Current Keyboard Shortcut 186 | 190 | {settings.toggleKeybinding ? formatKeyboardShortcut(settings.toggleKeybinding) : 'Disabled'} 191 | 192 |
193 |
194 |
195 | 196 | 197 | 203 | Open Full Settings 204 | 205 |
206 | {/if} 207 | {/if} 208 |
209 | 210 | 219 | -------------------------------------------------------------------------------- /entrypoints/popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Deadname Remover Popup Settings 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /entrypoints/popup/main.ts: -------------------------------------------------------------------------------- 1 | import 'virtual:uno.css' 2 | import { mount } from 'svelte' 3 | import App from './App.svelte' 4 | 5 | const app = mount(App, { 6 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 7 | target: document.getElementById('app')!, 8 | }) 9 | 10 | export default app 11 | -------------------------------------------------------------------------------- /entrypoints/popup/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | -webkit-text-size-adjust: 100%; 15 | } 16 | 17 | a { 18 | font-weight: 500; 19 | color: #646cff; 20 | text-decoration: inherit; 21 | } 22 | a:hover { 23 | color: #535bf2; 24 | } 25 | 26 | body { 27 | margin: 0; 28 | display: flex; 29 | place-items: center; 30 | min-width: 320px; 31 | min-height: 100vh; 32 | } 33 | 34 | h1 { 35 | font-size: 3.2em; 36 | line-height: 1.1; 37 | } 38 | 39 | #app { 40 | max-width: 1280px; 41 | margin: 0 auto; 42 | padding: 2rem; 43 | text-align: center; 44 | } 45 | 46 | .logo { 47 | height: 6em; 48 | padding: 1.5em; 49 | will-change: filter; 50 | transition: filter 300ms; 51 | } 52 | .logo:hover { 53 | filter: drop-shadow(0 0 2em #54bc4ae0); 54 | } 55 | .logo.vanilla:hover { 56 | filter: drop-shadow(0 0 2em #3178c6aa); 57 | } 58 | 59 | .card { 60 | padding: 2em; 61 | } 62 | 63 | .read-the-docs { 64 | color: #888; 65 | } 66 | 67 | button { 68 | border-radius: 8px; 69 | border: 1px solid transparent; 70 | padding: 0.6em 1.2em; 71 | font-size: 1em; 72 | font-weight: 500; 73 | font-family: inherit; 74 | background-color: #1a1a1a; 75 | cursor: pointer; 76 | transition: border-color 0.25s; 77 | } 78 | button:hover { 79 | border-color: #646cff; 80 | } 81 | button:focus, 82 | button:focus-visible { 83 | outline: 4px auto -webkit-focus-ring-color; 84 | } 85 | 86 | @media (prefers-color-scheme: light) { 87 | :root { 88 | color: #213547; 89 | background-color: #ffffff; 90 | } 91 | a:hover { 92 | color: #747bff; 93 | } 94 | button { 95 | background-color: #f9f9f9; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import eslint from '@eslint/js' 3 | import tseslint from 'typescript-eslint' 4 | import stylistic from '@stylistic/eslint-plugin' 5 | import eslintPluginSvelte from 'eslint-plugin-svelte' 6 | import * as svelteParser from 'svelte-eslint-parser' 7 | import * as typescriptParser from '@typescript-eslint/parser' 8 | import globals from 'globals' 9 | 10 | export default tseslint.config( 11 | eslint.configs.recommended, 12 | tseslint.configs.strictTypeChecked, 13 | tseslint.configs.stylisticTypeChecked, 14 | stylistic.configs.recommended, 15 | eslintPluginSvelte.configs['flat/recommended'], 16 | { 17 | languageOptions: { 18 | parserOptions: { 19 | project: './tsconfig.json', 20 | projectService: true, 21 | tsconfigRootDir: import.meta.dirname, 22 | }, 23 | }, 24 | rules: { 25 | '@typescript-eslint/no-unused-vars': [ 26 | 'error', 27 | { 28 | args: 'all', 29 | argsIgnorePattern: '^_', 30 | caughtErrors: 'all', 31 | caughtErrorsIgnorePattern: '^_', 32 | destructuredArrayIgnorePattern: '^_', 33 | varsIgnorePattern: '^_', 34 | ignoreRestSiblings: true, 35 | }, 36 | ], 37 | }, 38 | }, 39 | { 40 | files: ['**/*.svelte'], 41 | languageOptions: { 42 | parser: svelteParser, 43 | parserOptions: { 44 | parser: typescriptParser, 45 | project: './tsconfig.json', 46 | extraFileExtensions: ['.svelte'], 47 | }, 48 | globals: { 49 | ...globals.browser, 50 | }, 51 | }, 52 | }, 53 | { 54 | ignores: ['.output/**', '.wxt/**'], 55 | }, 56 | ) 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Deadname Remover", 3 | "version": "2.1.1", 4 | "description": "An easy to use browser plugin to automatically remove and replace deadnames", 5 | "private": true, 6 | "type": "module", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/arimgibson/Deadname-Remover.git" 10 | }, 11 | "keywords": [ 12 | "trans", 13 | "transgender", 14 | "dead", 15 | "name", 16 | "deadname", 17 | "lgbtq", 18 | "lgbtq+", 19 | "queer", 20 | "non-binary", 21 | "gender" 22 | ], 23 | "author": "Ari Gibson", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/arimgibson/Deadname-Remover/issues" 27 | }, 28 | "homepage": "https://github.com/arimgibson/Deadname-Remover#readme", 29 | "scripts": { 30 | "dev": "wxt", 31 | "dev:firefox": "wxt -b firefox", 32 | "build": "wxt build", 33 | "build:firefox": "wxt build -b firefox", 34 | "zip": "wxt zip", 35 | "zip:firefox": "wxt zip -b firefox", 36 | "lint": "eslint .", 37 | "check": "svelte-check --tsconfig ./tsconfig.json && tsc --noEmit", 38 | "test": "vitest", 39 | "compile": "tsc --noEmit", 40 | "postinstall": "wxt prepare" 41 | }, 42 | "devDependencies": { 43 | "@eslint/js": "^9.26.0", 44 | "@iconify-json/material-symbols": "^1.2.20", 45 | "@iconify-json/twemoji": "^1.2.2", 46 | "@onu-ui/preset": "^1.1.5", 47 | "@stylistic/eslint-plugin": "^4.2.0", 48 | "@types/chrome": "^0.0.319", 49 | "@typescript-eslint/parser": "^8.31.1", 50 | "@wxt-dev/module-svelte": "^2.0.3", 51 | "@wxt-dev/unocss": "^1.0.1", 52 | "eslint": "^9.26.0", 53 | "eslint-plugin-svelte": "^3.5.1", 54 | "globals": "^16.0.0", 55 | "svelte": "^5.28.2", 56 | "svelte-check": "^4.1.7", 57 | "svelte-eslint-parser": "^1.1.3", 58 | "typescript": "^5.8.3", 59 | "typescript-eslint": "^8.31.1", 60 | "unocss": "^66.1.0", 61 | "vitest": "^3.1.2", 62 | "wxt": "^0.20.6" 63 | }, 64 | "dependencies": { 65 | "compare-versions": "^6.1.1", 66 | "microdiff": "^1.5.0", 67 | "svelte-french-toast": "^1.2.0", 68 | "text-security": "^3.2.1", 69 | "valibot": "^1.0.0" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /public/icon/nb128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arimgibson/Deadname-Remover/fdbec606f817cdc121df586ecfc75de9f4fda8c6/public/icon/nb128.png -------------------------------------------------------------------------------- /public/icon/nb16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arimgibson/Deadname-Remover/fdbec606f817cdc121df586ecfc75de9f4fda8c6/public/icon/nb16.png -------------------------------------------------------------------------------- /public/icon/nb32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arimgibson/Deadname-Remover/fdbec606f817cdc121df586ecfc75de9f4fda8c6/public/icon/nb32.png -------------------------------------------------------------------------------- /public/icon/nb48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arimgibson/Deadname-Remover/fdbec606f817cdc121df586ecfc75de9f4fda8c6/public/icon/nb48.png -------------------------------------------------------------------------------- /public/icon/stealth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arimgibson/Deadname-Remover/fdbec606f817cdc121df586ecfc75de9f4fda8c6/public/icon/stealth.png -------------------------------------------------------------------------------- /public/icon/trans128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arimgibson/Deadname-Remover/fdbec606f817cdc121df586ecfc75de9f4fda8c6/public/icon/trans128.png -------------------------------------------------------------------------------- /public/icon/trans16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arimgibson/Deadname-Remover/fdbec606f817cdc121df586ecfc75de9f4fda8c6/public/icon/trans16.png -------------------------------------------------------------------------------- /public/icon/trans32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arimgibson/Deadname-Remover/fdbec606f817cdc121df586ecfc75de9f4fda8c6/public/icon/trans32.png -------------------------------------------------------------------------------- /public/icon/trans48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arimgibson/Deadname-Remover/fdbec606f817cdc121df586ecfc75de9f4fda8c6/public/icon/trans48.png -------------------------------------------------------------------------------- /services/configService.ts: -------------------------------------------------------------------------------- 1 | import { UserSettings } from '@/utils/types' 2 | import { storage } from '#imports' 3 | import { browser } from 'wxt/browser' 4 | import * as v from 'valibot' 5 | import { filterEmptyNamePairs } from '@/utils' 6 | 7 | export const defaultSettings: UserSettings = { 8 | names: { 9 | first: [], 10 | middle: [], 11 | last: [], 12 | email: [], 13 | }, 14 | enabled: true, 15 | blockContentBeforeDone: true, 16 | stealthMode: false, 17 | hideDebugInfo: false, 18 | highlightReplacedNames: true, 19 | syncSettingsAcrossDevices: false, 20 | theme: 'trans', 21 | toggleKeybinding: null, 22 | } 23 | 24 | export async function getConfig(): Promise { 25 | // Try local storage first 26 | const localConfig = await storage.getItem('local:nameConfig') 27 | if (localConfig) { 28 | return localConfig 29 | } 30 | 31 | // Check sync storage if no local config found 32 | const syncConfig = await storage.getItem('sync:nameConfig') 33 | if (syncConfig?.syncSettingsAcrossDevices) { 34 | return syncConfig 35 | } 36 | 37 | // Return default settings if no stored config is found 38 | return defaultSettings 39 | } 40 | 41 | export async function setConfig(settings: UserSettings): Promise { 42 | // temp migrations to prevent breaking changes 43 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 44 | if (settings.toggleKeybinding === undefined) { 45 | settings.toggleKeybinding = null 46 | } 47 | 48 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 49 | if (settings.names.email === undefined || settings.names.email.length === 0) { 50 | settings.names.email = [] 51 | } 52 | 53 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 54 | settings.hideDebugInfo ??= false 55 | 56 | const cleanedSettings = { 57 | ...settings, 58 | names: filterEmptyNamePairs(settings.names), 59 | } 60 | 61 | const validatedConfig = v.parse(UserSettings, cleanedSettings) 62 | const previousConfig = await getConfig() 63 | 64 | // Handle storage sync preference change 65 | if (previousConfig.syncSettingsAcrossDevices !== validatedConfig.syncSettingsAcrossDevices) { 66 | if (validatedConfig.syncSettingsAcrossDevices) { 67 | // Switching TO sync: move to sync storage and clean up local 68 | await storage.setItem('sync:nameConfig', validatedConfig) 69 | await storage.removeItem('local:nameConfig') 70 | } 71 | else { 72 | // Switching FROM sync: just store in local 73 | await storage.setItem('local:nameConfig', validatedConfig) 74 | } 75 | } 76 | else { 77 | // No sync preference change, store in appropriate storage 78 | const storageKey = validatedConfig.syncSettingsAcrossDevices ? 'sync:nameConfig' : 'local:nameConfig' 79 | await storage.setItem(storageKey, validatedConfig) 80 | } 81 | 82 | // Stealth Mode and Theme require updates unrelated to the content script 83 | // all other changes are automatically updated in the content script 84 | // via setupConfigListener and a callback to process the page 85 | if (previousConfig.stealthMode !== validatedConfig.stealthMode) { 86 | await handleStealthModeChange(validatedConfig.stealthMode) 87 | } 88 | 89 | if (previousConfig.theme !== validatedConfig.theme) { 90 | await handleThemeChange(validatedConfig.theme, validatedConfig.stealthMode) 91 | } 92 | } 93 | 94 | export function setupConfigListener(callback: (config: UserSettings) => void) { 95 | // Watch sync storage 96 | storage.watch( 97 | 'sync:nameConfig', 98 | (config) => { 99 | if (config?.syncSettingsAcrossDevices) { 100 | callback(config) 101 | } 102 | }, 103 | ) 104 | 105 | // Watch local storage 106 | storage.watch( 107 | 'local:nameConfig', 108 | (config) => { 109 | if (config && !config.syncSettingsAcrossDevices) { 110 | callback(config) 111 | } 112 | }, 113 | ) 114 | } 115 | 116 | export async function updateExtensionAppearance(stealthMode: boolean, theme: UserSettings['theme'] = 'trans'): Promise { 117 | if (stealthMode) { 118 | await Promise.all([ 119 | // See https://wxt.dev/guide/essentials/extension-apis.html#feature-detection 120 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 121 | (browser.action ?? browser.browserAction).setIcon({ path: 'icon/stealth.png' }), 122 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 123 | (browser.action ?? browser.browserAction).setTitle({ title: 'An experimental content filter' }), 124 | ]) 125 | return 126 | } 127 | 128 | // Update icon based on theme 129 | const iconPath = theme === 'non-binary' ? 'icon/nb16.png' : 'icon/trans16.png' 130 | await Promise.all([ 131 | // See https://wxt.dev/guide/essentials/extension-apis.html#feature-detection 132 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 133 | (browser.action ?? browser.browserAction).setIcon({ path: iconPath }), 134 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 135 | (browser.action ?? browser.browserAction).setTitle({ title: 'Deadname Remover Settings' }), 136 | ]) 137 | } 138 | 139 | async function handleStealthModeChange(enabled: boolean): Promise { 140 | await updateExtensionAppearance(enabled) 141 | } 142 | 143 | async function handleThemeChange(theme: UserSettings['theme'], stealthMode: boolean): Promise { 144 | await updateExtensionAppearance(stealthMode, theme) 145 | } 146 | 147 | export async function deleteSyncedData(isSynced: boolean): Promise { 148 | if (isSynced) { 149 | const config = await getConfig() 150 | await storage.setItem('local:nameConfig', { 151 | ...config, 152 | syncSettingsAcrossDevices: false, 153 | }) 154 | } 155 | await storage.removeItem('sync:nameConfig') 156 | } 157 | -------------------------------------------------------------------------------- /services/domObserver.ts: -------------------------------------------------------------------------------- 1 | import { TextProcessor } from './textProcessor' 2 | 3 | export class DOMObserver { 4 | private observer: MutationObserver | null = null 5 | private textProcessor: TextProcessor 6 | /** Maximum depth for DOM traversal to prevent excessive processing on deeply nested structures */ 7 | private static readonly MAX_PROCESSING_DEPTH = 10 8 | 9 | constructor(textProcessor: TextProcessor) { 10 | this.textProcessor = textProcessor 11 | } 12 | 13 | setup(replacements: Map): void { 14 | // Clean up any existing observer 15 | this.disconnect() 16 | 17 | const processChanges = (mutations: MutationRecord[]) => { 18 | for (const mutation of mutations) { 19 | if (mutation.type === 'childList') { 20 | mutation.addedNodes.forEach((node) => { 21 | if (node instanceof HTMLElement) { 22 | void this.textProcessor.processSubtree(node, replacements, true) 23 | } 24 | else if (node.nodeType === Node.TEXT_NODE && node.parentElement) { 25 | // Process the parent element if needed. 26 | void this.textProcessor.processSubtree(node.parentElement, replacements, true) 27 | } 28 | }) 29 | } 30 | else if (mutation.type === 'characterData') { 31 | // For text changes, we still need to process the parent element 32 | // to ensure we catch all related changes 33 | const parentElement = mutation.target.parentElement 34 | if (parentElement) { 35 | void this.textProcessor.processSubtree(parentElement, replacements, true) 36 | } 37 | } 38 | } 39 | } 40 | 41 | let mutationQueue: MutationRecord[] = [] 42 | let queued = false 43 | 44 | const processQueuedMutations = () => { 45 | // Process all queued mutations at once 46 | processChanges(mutationQueue) 47 | mutationQueue = [] 48 | queued = false 49 | } 50 | 51 | this.observer = new MutationObserver((mutations) => { 52 | mutationQueue.push(...mutations) 53 | if (!queued) { 54 | queued = true 55 | requestAnimationFrame(processQueuedMutations) 56 | } 57 | }) 58 | 59 | this.observer.observe(document.body, { 60 | childList: true, 61 | subtree: true, 62 | characterData: true, 63 | }) 64 | } 65 | 66 | disconnect(): void { 67 | if (this.observer) { 68 | this.observer.disconnect() 69 | this.observer = null 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /services/textProcessor.ts: -------------------------------------------------------------------------------- 1 | import { getDataKey } from '@/entrypoints/content/utils' 2 | import { debugLog } from '@/utils' 3 | import type { ReplacementsMap } from '@/utils/types' 4 | 5 | interface TextMatch { 6 | text: string 7 | index: number 8 | pattern: RegExp 9 | replacement: string 10 | } 11 | 12 | export const createReplacementPattern = (name: string): RegExp => { 13 | const escaped = name 14 | .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') 15 | .replace(/['']/g, '[\'\']') 16 | .replace(/[-]/g, '[-]') 17 | // case insensitive and unicode -- case matching is done in the replaceTextInNode method 18 | return new RegExp(`(? Aaron 29 | isaBella --> aarOn 30 | isabeLLA --> aaron (because Aaron is a shorter name, i.e. doesn't have 6th character to capitalize) 31 | */ 32 | function caseMatchReplacement(original: string, replacement: string): string { 33 | if (original === original.toUpperCase()) return replacement.toUpperCase() 34 | if (original === original.toLowerCase()) return replacement.toLowerCase() 35 | return replacement.charAt(0).toUpperCase() + replacement.slice(1) 36 | } 37 | 38 | export class TextProcessor { 39 | private static originalTitle: string | null = null 40 | private processedNodes = new WeakSet() 41 | private metrics = { 42 | nodesProcessed: 0, 43 | replacementsMade: 0, 44 | accessibilityAttributesUpdated: 0, 45 | processingTime: 0, 46 | } 47 | 48 | static readonly accessibilityAttributes = [ 49 | 'alt', 50 | 'aria-label', 51 | 'aria-description', 52 | 'title', 53 | 'placeholder', 54 | ] as const 55 | 56 | getMetrics() { 57 | return { ...this.metrics } 58 | } 59 | 60 | resetMetrics(): void { 61 | this.metrics = { 62 | nodesProcessed: 0, 63 | replacementsMade: 0, 64 | accessibilityAttributesUpdated: 0, 65 | processingTime: 0, 66 | } 67 | } 68 | 69 | /** 70 | * Processes the subtree either asynchronously (in batches) or synchronously. 71 | * @param root - The root element. 72 | * @param replacements - Map of replacement regex patterns and strings. 73 | * @param asyncProcessing - If true, process nodes in batches asynchronously; if false, process synchronously. 74 | * 75 | * When asyncProcessing is false, the entire subtree is processed in one go (which is useful 76 | * for the initial page load when you need to block until processing finishes). 77 | */ 78 | processSubtree( 79 | root: HTMLElement, 80 | replacements: ReplacementsMap, 81 | asyncProcessing = true, 82 | ): Promise | void { 83 | if (asyncProcessing) { 84 | return new Promise((resolve) => { 85 | const iterator = this.createNodeIterator(root) 86 | const batchSize = 30 // Adjust as needed. 87 | let count = 0 88 | let node: Node | null = null 89 | 90 | const processBatch = () => { 91 | count = 0 92 | while (count < batchSize && (node = iterator.nextNode())) { 93 | if (node.nodeType === Node.TEXT_NODE) { 94 | const textNode = node as Text 95 | if (!this.processedNodes.has(textNode)) { 96 | const matches = this.findMatches(textNode.nodeValue ?? '', replacements) 97 | if (matches.length > 0) { 98 | this.replaceTextInNode(textNode, matches) 99 | } 100 | } 101 | } 102 | else if (node.nodeType === Node.ELEMENT_NODE) { 103 | if (this.shouldProcessElement(node as HTMLElement)) { 104 | this.processElementNode(node, replacements) 105 | } 106 | } 107 | count++ 108 | } 109 | if (!node) { 110 | // No more nodes to process. 111 | resolve() 112 | } 113 | else { 114 | // Schedule the next batch. 115 | requestAnimationFrame(processBatch) 116 | } 117 | } 118 | 119 | requestAnimationFrame(processBatch) 120 | }) 121 | } 122 | else { 123 | // Synchronous processing. 124 | const iterator = this.createNodeIterator(root) 125 | let node: Node | null 126 | while ((node = iterator.nextNode())) { 127 | if (node.nodeType === Node.TEXT_NODE) { 128 | const textNode = node as Text 129 | if (!this.processedNodes.has(textNode)) { 130 | const matches = this.findMatches(textNode.nodeValue ?? '', replacements) 131 | if (matches.length > 0) { 132 | this.replaceTextInNode(textNode, matches) 133 | } 134 | } 135 | } 136 | else if (node.nodeType === Node.ELEMENT_NODE) { 137 | if (this.shouldProcessElement(node as HTMLElement)) { 138 | this.processElementNode(node, replacements) 139 | } 140 | } 141 | } 142 | } 143 | } 144 | 145 | async processDocument({ 146 | root, 147 | replacements, 148 | asyncProcessing = true, 149 | }: { 150 | root: HTMLElement 151 | replacements: ReplacementsMap 152 | asyncProcessing?: boolean 153 | }): Promise { 154 | const startTime = performance.now() 155 | 156 | // Process document title synchronously. 157 | if (document.title) { 158 | TextProcessor.originalTitle = document.title 159 | replacements.forEach((replacement, pattern) => { 160 | if (pattern.test(document.title)) { 161 | document.title = document.title.replace(pattern, match => 162 | caseMatchReplacement(match, replacement), 163 | ) 164 | this.metrics.replacementsMade++ 165 | } 166 | }) 167 | } 168 | 169 | await this.processSubtree(root, replacements, asyncProcessing) 170 | 171 | this.metrics.processingTime = performance.now() - startTime 172 | 173 | // @ts-expect-error -- values are defined by WXT 174 | if ((import.meta.env as { DEV: boolean }).DEV) { 175 | await debugLog('replacement metrics', { 176 | nodesProcessed: this.metrics.nodesProcessed, 177 | replacementsMade: this.metrics.replacementsMade, 178 | accessibilityAttributesUpdated: this.metrics.accessibilityAttributesUpdated, 179 | processingTime: `${this.metrics.processingTime.toFixed(2)}ms`, 180 | }) 181 | } 182 | } 183 | 184 | private createNodeIterator(root: HTMLElement) { 185 | return document.createNodeIterator( 186 | root, 187 | NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, 188 | { 189 | acceptNode: (node) => { 190 | if (node.nodeType === Node.TEXT_NODE) { 191 | const textNode = node as Text 192 | return this.shouldProcessText(textNode) 193 | ? NodeFilter.FILTER_ACCEPT 194 | : NodeFilter.FILTER_REJECT 195 | } 196 | 197 | if (node.nodeType === Node.ELEMENT_NODE) { 198 | return this.shouldProcessElement(node as HTMLElement) 199 | ? NodeFilter.FILTER_ACCEPT 200 | : NodeFilter.FILTER_SKIP 201 | } 202 | 203 | // Path shouldn't reach here 204 | return NodeFilter.FILTER_ACCEPT 205 | }, 206 | }, 207 | ) 208 | } 209 | 210 | private findMatches(text: string, replacements: ReplacementsMap): TextMatch[] { 211 | const matches: TextMatch[] = [] 212 | 213 | for (const [pattern, replacement] of replacements) { 214 | pattern.lastIndex = 0 // Reset for global patterns 215 | let match 216 | while ((match = pattern.exec(text)) !== null) { 217 | matches.push({ 218 | text: match[0], 219 | index: match.index, 220 | pattern, 221 | replacement, 222 | }) 223 | } 224 | } 225 | 226 | // Need to sort matches by index to ensure they are processed in order 227 | // since we are looping through multiple replacement patterns 228 | return matches.sort((a, b) => a.index - b.index) 229 | } 230 | 231 | private processElementNode(node: Node, replacements: ReplacementsMap): void { 232 | this.metrics.nodesProcessed++ 233 | 234 | const element = node as HTMLElement 235 | 236 | for (const attr of TextProcessor.accessibilityAttributes) { 237 | if (element.hasAttribute(attr)) { 238 | const value = element.getAttribute(attr) 239 | if (value) { 240 | let newValue = value 241 | replacements.forEach((replacement, pattern) => { 242 | newValue = newValue.replaceAll(pattern, match => 243 | caseMatchReplacement(match, replacement), 244 | ) 245 | }) 246 | 247 | if (newValue !== value) { 248 | // Store original value before replacement (camelCase) 249 | element.dataset[getDataKey(attr)] = value 250 | element.setAttribute(attr, newValue) 251 | this.metrics.accessibilityAttributesUpdated++ 252 | } 253 | } 254 | } 255 | } 256 | } 257 | 258 | private replaceTextInNode(textNode: Text, matches: TextMatch[]): boolean { 259 | const originalText = textNode.nodeValue 260 | if (!originalText || !textNode.parentNode) return false 261 | 262 | let lastIndex = 0 263 | // Create a fragment to hold the text and marks before replacement 264 | const fragments = new DocumentFragment() 265 | 266 | matches.forEach(({ text, index, replacement }) => { 267 | // Add any unmatched text that appears before the current match to the container 268 | if (index > lastIndex) { 269 | fragments.appendChild( 270 | document.createTextNode(originalText.slice(lastIndex, index)), 271 | ) 272 | } 273 | 274 | // Create marked replacement (highlight determined by theme, set in style) 275 | const markElement = document.createElement('mark') 276 | markElement.setAttribute('deadname', '') 277 | markElement.dataset.original = text 278 | markElement.textContent = caseMatchReplacement(text, replacement) 279 | fragments.appendChild(markElement) 280 | 281 | this.metrics.replacementsMade++ 282 | 283 | lastIndex = index + text.length 284 | }) 285 | 286 | // Add any remaining text that appears after the last match to the container 287 | if (lastIndex < originalText.length) { 288 | fragments.appendChild( 289 | document.createTextNode(originalText.slice(lastIndex)), 290 | ) 291 | } 292 | 293 | textNode.parentNode.replaceChild(fragments, textNode) 294 | this.processedNodes.add(textNode) 295 | return true 296 | } 297 | 298 | private shouldProcessText(textNode: Text): boolean { 299 | // https://developer.mozilla.org/en-US/docs/Web/HTML/Element#forms 300 | const formElements = [ 301 | 'datalist', 302 | 'fieldset', 303 | 'form', 304 | 'input', 305 | 'optgroup', 306 | 'option', 307 | 'select', 308 | 'textarea', 309 | ] 310 | 311 | // https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes 312 | // https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes 313 | // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes 314 | const excludedAttributes: Record = { 315 | 'contenteditable': ['true'], 316 | 'role': [ 317 | 'checkbox', 318 | 'input', 319 | 'option', 320 | 'searchbox', 321 | 'select', 322 | 'slider', 323 | 'spinbutton', 324 | 'switch', 325 | 'textbox', 326 | ], 327 | 'spellcheck': ['true'], // Often indicates editable content 328 | // ARIA attributes 329 | 'aria-autocomplete': ['true'], 330 | 'aria-multiline': ['true'], 331 | 'aria-readonly': ['false'], 332 | 'aria-disabled': ['false'], 333 | 'data-editable': ['true'], // Common custom attribute 334 | } 335 | 336 | // Check if any ancestor element is a form element 337 | let currentElement: HTMLElement | null = textNode.parentElement 338 | while (currentElement) { 339 | if ( 340 | formElements.includes(currentElement.tagName.toLowerCase()) 341 | || !this.shouldProcessElement(currentElement) 342 | ) { 343 | return false 344 | } 345 | 346 | // Check for excluded attributes 347 | for (const [attr, values] of Object.entries(excludedAttributes)) { 348 | const attrValue = currentElement.getAttribute(attr)?.toLowerCase() 349 | if (attrValue !== undefined && values.includes(attrValue)) { 350 | return false 351 | } 352 | } 353 | 354 | currentElement = currentElement.parentElement 355 | } 356 | 357 | return true 358 | } 359 | 360 | private shouldProcessElement(element: HTMLElement): boolean { 361 | return !( 362 | (element.tagName.toLowerCase() === 'mark' && element.hasAttribute('deadname')) 363 | || element.tagName.toLowerCase() === 'script' 364 | || element.tagName.toLowerCase() === 'style' 365 | || element.tagName.toLowerCase() === 'noscript' 366 | || element.tagName.toLowerCase() === 'template' 367 | || element.tagName.toLowerCase() === 'svg' 368 | ) 369 | } 370 | 371 | static revertAllReplacements(): void { 372 | void debugLog('reverting all replacements') 373 | 374 | // Revert document title 375 | if (TextProcessor.originalTitle !== null) { 376 | document.title = TextProcessor.originalTitle 377 | TextProcessor.originalTitle = null 378 | } 379 | 380 | TextProcessor.accessibilityAttributes.forEach((attr) => { 381 | const selector = `[data-deadname-${attr}]` 382 | const dataKey = getDataKey(attr) 383 | document.querySelectorAll(selector).forEach((el) => { 384 | const original = (el as HTMLElement).dataset[dataKey] 385 | if (original) { 386 | (el as HTMLElement).setAttribute(attr, original) 387 | // eslint-disable-next-line @typescript-eslint/no-dynamic-delete 388 | delete (el as HTMLElement).dataset[dataKey] 389 | } 390 | }) 391 | }) 392 | 393 | const replacedMarks = document.querySelectorAll('mark[deadname]') 394 | replacedMarks.forEach((mark) => { 395 | const original = (mark as HTMLElement).dataset.original 396 | if (original) { 397 | const textNode = document.createTextNode(original) 398 | mark.parentNode?.replaceChild(textNode, mark) 399 | } 400 | }) 401 | } 402 | } 403 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.wxt/tsconfig.json", 3 | "compilerOptions": { 4 | "useDefineForClassFields": true, 5 | "strict": true, 6 | "allowJs": true, 7 | "checkJs": true 8 | }, 9 | "include": ["**/*.ts", "**/*.svelte", "eslint.config.js", ".wxt/types"] 10 | } 11 | -------------------------------------------------------------------------------- /uno.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'unocss' 2 | import { presetOnu } from '@onu-ui/preset' 3 | 4 | export default defineConfig({ 5 | shortcuts: { 6 | 'input-error': '[.peer[aria-invalid=true]~&]:block hidden', 7 | 'name-pair-row-grid': 'grid grid-cols-[47%_47%_6%]', 8 | 'link': 'text-primary hover:text-primary-600 underline hover:underline-offset-4 transition-all', 9 | 'right-tooltip': 'absolute left-full top-1/2 -translate-y-1/2 ml-2 w-max px-3 py-2 bg-white text-gray-700 rounded shadow-xl', 10 | 'accessible-switch': 'switch has-[:focus-visible]:ring has-[:focus-visible]:ring-black has-[:focus-visible]:ring-offset-2', 11 | }, 12 | presets: [ 13 | presetOnu({ 14 | color: '#8B5CF6', 15 | }), 16 | ], 17 | theme: { 18 | animation: { 19 | keyframes: { 20 | 'fade-in-right-horizontal': '{from{opacity:0;transform:translate(100%,-50%)}to{opacity:1;transform:translate(0,-50%)}}', 21 | }, 22 | durations: { 23 | 'fade-in-right-horizontal': '0.5s', 24 | }, 25 | timingFns: { 26 | 'fade-in-right-horizontal': 'ease-in', 27 | }, 28 | }, 29 | }, 30 | }) 31 | -------------------------------------------------------------------------------- /utils/__tests__/validations.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { validateNoRecursiveMappings, validateNoDuplicateDeadnames, validateNoSelfMappings } from '../validations' 3 | import type { Names } from '../types' 4 | 5 | describe('validateNoDuplicateDeadnames', () => { 6 | it('should return true for no names provided', () => { 7 | const nameMappings: Names = { 8 | first: [], 9 | middle: [], 10 | last: [], 11 | email: [], 12 | } 13 | const result = validateNoDuplicateDeadnames(nameMappings) 14 | expect(result).toBe(true) 15 | }) 16 | 17 | it('should return true for no duplicate deadnames', () => { 18 | const nameMappings: Names = { 19 | first: [{ mappings: ['John', 'Doe'] }], 20 | middle: [], 21 | last: [], 22 | email: [], 23 | } 24 | const result = validateNoDuplicateDeadnames(nameMappings) 25 | expect(result).toBe(true) 26 | }) 27 | 28 | it('should return true for duplicate proper names in same category', () => { 29 | const nameMappings: Names = { 30 | first: [{ mappings: ['John', 'Doe'] }, { mappings: ['Smith', 'Doe'] }], 31 | middle: [], 32 | last: [], 33 | email: [], 34 | } 35 | const result = validateNoDuplicateDeadnames(nameMappings) 36 | expect(result).toBe(true) 37 | }) 38 | 39 | it('should return true for duplicate proper names in different categories', () => { 40 | const nameMappings: Names = { 41 | first: [{ mappings: ['John', 'Doe'] }], 42 | middle: [{ mappings: ['Smith', 'Doe'] }], 43 | last: [], 44 | email: [], 45 | } 46 | const result = validateNoDuplicateDeadnames(nameMappings) 47 | expect(result).toBe(true) 48 | }) 49 | 50 | it('should return true for no duplicate deadnames (complex case)', () => { 51 | const nameMappings: Names = { 52 | first: [{ mappings: ['John', 'Emma'] }, { mappings: ['Josh', 'Rachel'] }], 53 | middle: [{ mappings: ['Jessica', 'Rachel'] }, { mappings: ['Olivia', 'Emma'] }], 54 | last: [{ mappings: ['Smith', 'Rachel'] }, { mappings: ['Miller', 'Emma'] }], 55 | email: [], 56 | } 57 | const result = validateNoDuplicateDeadnames(nameMappings) 58 | expect(result).toBe(true) 59 | }) 60 | 61 | it('should return false for duplicate deadnames in same category', () => { 62 | const nameMappings: Names = { 63 | first: [{ mappings: ['John', 'Doe'] }, { mappings: ['John', 'Smith'] }], 64 | middle: [], 65 | last: [], 66 | email: [], 67 | } 68 | const result = validateNoDuplicateDeadnames(nameMappings) 69 | expect(result).toBe(false) 70 | }) 71 | 72 | it('should return false for duplicate deadnames in different categories', () => { 73 | const nameMappings: Names = { 74 | first: [{ mappings: ['John', 'Doe'] }], 75 | middle: [{ mappings: ['John', 'Smith'] }], 76 | last: [], 77 | email: [], 78 | } 79 | const result = validateNoDuplicateDeadnames(nameMappings) 80 | expect(result).toBe(false) 81 | }) 82 | 83 | it('should return false for multiple duplicate deadnames', () => { 84 | const nameMappings: Names = { 85 | first: [{ mappings: ['John', 'Doe'] }, { mappings: ['John', 'Smith'] }], 86 | middle: [], 87 | last: [{ mappings: ['Smith', 'Doe'] }, { mappings: ['Smith', 'Smith'] }], 88 | email: [], 89 | } 90 | const result = validateNoDuplicateDeadnames(nameMappings) 91 | expect(result).toBe(false) 92 | }) 93 | }) 94 | 95 | describe('validateNoSelfMappings', () => { 96 | it('should return true for no names provided', () => { 97 | const nameMappings: Names = { 98 | first: [], 99 | middle: [], 100 | last: [], 101 | email: [], 102 | } 103 | const result = validateNoSelfMappings(nameMappings) 104 | expect(result).toBe(true) 105 | }) 106 | 107 | it('should return true for no self mappings', () => { 108 | const nameMappings: Names = { 109 | first: [{ mappings: ['John', 'Doe'] }], 110 | middle: [], 111 | last: [], 112 | email: [], 113 | } 114 | const result = validateNoSelfMappings(nameMappings) 115 | expect(result).toBe(true) 116 | }) 117 | 118 | it('should return false for self mappings in the same category', () => { 119 | const nameMappings: Names = { 120 | first: [{ mappings: ['John', 'John'] }], 121 | middle: [], 122 | last: [], 123 | email: [], 124 | } 125 | const result = validateNoSelfMappings(nameMappings) 126 | expect(result).toBe(false) 127 | }) 128 | 129 | it('should return false for multiple self mappings', () => { 130 | const nameMappings: Names = { 131 | first: [{ mappings: ['John', 'Doe'] }, { mappings: ['Doe', 'Doe'] }], 132 | middle: [], 133 | last: [{ mappings: ['Smith', 'Smith'] }], 134 | email: [], 135 | } 136 | const result = validateNoSelfMappings(nameMappings) 137 | expect(result).toBe(false) 138 | }) 139 | }) 140 | 141 | describe('validateNoRecursiveMappings', () => { 142 | it('should return true for no names provided', () => { 143 | const nameMappings: Names = { 144 | first: [], 145 | middle: [], 146 | last: [], 147 | email: [], 148 | } 149 | const result = validateNoRecursiveMappings(nameMappings) 150 | expect(result).toBe(true) 151 | }) 152 | 153 | it('should return true for no recursive mappings', () => { 154 | const nameMappings: Names = { 155 | first: [{ mappings: ['John', 'Doe'] }], 156 | middle: [], 157 | last: [{ mappings: ['Smith', 'Doe'] }], 158 | email: [], 159 | } 160 | const result = validateNoRecursiveMappings(nameMappings) 161 | expect(result).toBe(true) 162 | }) 163 | 164 | it('should return false for same-category recursive mappings', () => { 165 | const nameMappings: Names = { 166 | first: [{ mappings: ['John', 'Doe'] }, { mappings: ['Doe', 'Smith'] }], 167 | middle: [], 168 | last: [], 169 | email: [], 170 | } 171 | const result = validateNoRecursiveMappings(nameMappings) 172 | expect(result).toBe(false) 173 | }) 174 | 175 | it('should return false for cross-category recursive mappings', () => { 176 | const nameMappings: Names = { 177 | first: [{ mappings: ['John', 'Doe'] }], 178 | middle: [{ mappings: ['Doe', 'Smith'] }], 179 | last: [], 180 | email: [], 181 | } 182 | const result = validateNoRecursiveMappings(nameMappings) 183 | expect(result).toBe(false) 184 | }) 185 | 186 | it('should return false for multiple cross-category mappings', () => { 187 | const nameMappings: Names = { 188 | first: [{ mappings: ['John', 'Doe'] }], 189 | middle: [{ mappings: ['Doe', 'Smith'] }], 190 | last: [{ mappings: ['Smith', 'John'] }], 191 | email: [], 192 | } 193 | const result = validateNoRecursiveMappings(nameMappings) 194 | expect(result).toBe(false) 195 | }) 196 | }) 197 | -------------------------------------------------------------------------------- /utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const nameKeys = [ 2 | { 3 | label: 'First Names', 4 | value: 'first', 5 | }, 6 | { 7 | label: 'Middle Names', 8 | value: 'middle', 9 | }, 10 | { 11 | label: 'Last Names', 12 | value: 'last', 13 | }, 14 | { 15 | label: 'Email Addresses', 16 | value: 'email', 17 | }, 18 | ] as const 19 | 20 | export const generalSettingKeys = [ 21 | { 22 | label: 'Enable Extension', 23 | value: 'enabled', 24 | description: 'Enable or disable the extension\'s name replacement functionality.', 25 | }, 26 | { 27 | label: 'Stealth Mode', 28 | value: 'stealthMode', 29 | description: 30 | 'Hide gender-related elements to protect privacy and accidentally being outed. Replaces the extension icon, and hides the popup options when the extension\'s icon is clicked. Recommended to disable text highlighting as well.', 31 | }, 32 | { 33 | label: 'Hide Debug Info', 34 | value: 'hideDebugInfo', 35 | description: 36 | 'Hide debug information from the browser console. This can help protect your privacy by not exposing potentially sensitive information.', 37 | }, 38 | { 39 | label: 'Block Page Until Replacements Finished', 40 | value: 'blockContentBeforeDone', 41 | description: 42 | 'Block the page until all replacements are finished to avoid displaying a deadname before replacement. Can cause slowdowns on lower-end devices.', 43 | }, 44 | { 45 | label: 'Highlight Replaced Names', 46 | value: 'highlightReplacedNames', 47 | description: 'Highlight replaced names to make them easier to spot.', 48 | }, 49 | { 50 | label: 'Sync Settings Across Devices', 51 | value: 'syncSettingsAcrossDevices', 52 | description: 'Sync settings across devices signed into the same browser profile (e.g. Google account).', 53 | }, 54 | ] as const 55 | 56 | export const themeKeys = [ 57 | { 58 | label: 'Trans', 59 | value: 'trans', 60 | }, 61 | { 62 | label: 'Non-Binary', 63 | value: 'non-binary', 64 | }, 65 | { 66 | label: 'High Contrast (Yellow)', 67 | value: 'high-contrast', 68 | }, 69 | ] as const 70 | -------------------------------------------------------------------------------- /utils/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unnecessary-condition */ 2 | import type { Difference } from 'microdiff' 3 | import { Names, UserSettings } from './types' 4 | import { getConfig, setConfig } from '@/services/configService' 5 | 6 | export async function debugLog(message: string, ...data: unknown[]) { 7 | const { hideDebugInfo } = await getConfig() 8 | if (hideDebugInfo) return 9 | 10 | console.debug(`Deadname Remover: ${message}`, ...data) 11 | } 12 | 13 | export function errorLog(message: string, ...data: unknown[]) { 14 | console.error(`Deadname Remover: ${message}`, ...data) 15 | } 16 | 17 | /** 18 | * Filters out empty arrays from the diff generated by microdiff 19 | * @param diff - The diff to filter 20 | * @returns The filtered diff 21 | */ 22 | export function filterEmptyArraysFromDiff(diff: Difference[]) { 23 | return diff.filter(change => 24 | change.type !== 'CREATE' 25 | || !(Array.isArray(change.value) && change.value[0] === '' && change.value[1] === ''), 26 | ) 27 | } 28 | 29 | /** 30 | * Compares two Names arrays deeply to check for any changes 31 | * @param previous - Previous Names array 32 | * @param current - Current Names array 33 | * @returns boolean - true if arrays are different, false if they're the same 34 | */ 35 | export function haveNamesChanged(previous: Names | undefined, current: Names): boolean { 36 | if (!previous) return true 37 | 38 | // Compare each category (first, middle, last names) 39 | for (const category in previous) { 40 | const prevNames = previous[category as keyof Names] 41 | const currNames = current[category as keyof Names] 42 | 43 | // Check if arrays exist and have same length 44 | if (prevNames.length !== currNames.length) { 45 | return true 46 | } 47 | 48 | // Compare each [deadname, chosenname] tuple 49 | for (let i = 0; i < prevNames.length; i++) { 50 | const [prevDead, prevChosen] = prevNames[i].mappings 51 | const [currDead, currChosen] = currNames[i].mappings 52 | 53 | if (prevDead !== currDead || prevChosen !== currChosen) { 54 | return true 55 | } 56 | } 57 | } 58 | 59 | return false 60 | } 61 | 62 | export function kebabToCamel(str: string): string { 63 | return str.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()) 64 | } 65 | 66 | /** 67 | * Formats a keyboard shortcut object into a human-readable string. 68 | * 69 | * @param shortcut - The shortcut object to format. 70 | * @returns A string representation of the shortcut, or null if the shortcut is undefined. 71 | */ 72 | export function formatKeyboardShortcut(shortcut: UserSettings['toggleKeybinding']): string | null { 73 | if (!shortcut) return null 74 | 75 | const parts: string[] = [] 76 | 77 | if (shortcut.ctrl) parts.push('Ctrl') 78 | if (shortcut.alt) parts.push('Alt') 79 | if (shortcut.shift) parts.push('Shift') 80 | if (shortcut.meta) parts.push('Meta') 81 | 82 | // Format key nicely 83 | let key = shortcut.key 84 | if (key === ' ') key = 'Space' 85 | else if (key.length === 1) key = key.toUpperCase() 86 | else if (key === 'Escape') key = 'Esc' 87 | 88 | parts.push(key) 89 | 90 | return parts.join(' + ') 91 | } 92 | 93 | /** 94 | * Filters out empty name pairs (where both deadname and proper name are empty) 95 | * from all name categories 96 | * @param nameMappings - Original name mappings object 97 | * @returns Names - Filtered name mappings with empty pairs removed 98 | */ 99 | export function filterEmptyNamePairs(nameMappings: Names): Names { 100 | const result: Names = { 101 | first: [], 102 | middle: [], 103 | last: [], 104 | email: [], 105 | } 106 | 107 | for (const category of ['first', 'middle', 'last', 'email'] as const) { 108 | result[category] = nameMappings?.[category]?.filter( 109 | pair => !(pair.mappings[0] === '' && pair.mappings[1] === ''), 110 | ) ?? [] 111 | } 112 | 113 | return result 114 | } 115 | 116 | /** 117 | * Registers a keyboard shortcut for the enabling and disabling the extension 118 | * @param config - The user's current config 119 | * @param listener - The existing listener to remove, if it exists 120 | * @returns A new listener function, or null if no listener was provided 121 | */ 122 | export async function registerKeyboardShortcut({ 123 | config, 124 | listener, 125 | }: { 126 | config: UserSettings 127 | listener: ((event: KeyboardEvent) => void) | null 128 | }): Promise<((event: KeyboardEvent) => void) | null> { 129 | if (listener) { 130 | document.removeEventListener('keydown', listener, true) 131 | } 132 | 133 | const toggleKeybinding = config.toggleKeybinding 134 | if (!toggleKeybinding) { 135 | await debugLog('no toggle keybinding found, skipping keyboard shortcut registration') 136 | return null 137 | } 138 | 139 | await debugLog('registering keyboard shortcut', toggleKeybinding) 140 | 141 | // Create a new listener function and store reference 142 | listener = (event: KeyboardEvent) => { 143 | // Skip the event if originates from editable element 144 | const tagName = (event.target as HTMLElement).tagName.toLowerCase() 145 | if (['input', 'textarea', 'select'].includes(tagName) 146 | || (event.target as HTMLElement).isContentEditable) { 147 | return 148 | } 149 | 150 | if (event.key === toggleKeybinding.key 151 | && event.altKey === toggleKeybinding.alt 152 | && event.ctrlKey === toggleKeybinding.ctrl 153 | && event.shiftKey === toggleKeybinding.shift 154 | && event.metaKey === toggleKeybinding.meta 155 | ) { 156 | event.preventDefault() 157 | void (async () => { 158 | await debugLog(`toggle keybinding pressed, ${config.enabled ? 'disabling' : 'enabling'}`) 159 | config.enabled = !config.enabled 160 | void setConfig(config) 161 | })() 162 | } 163 | } 164 | 165 | // Add the new listener with capturing (true as third parameter) 166 | document.addEventListener('keydown', listener, true) 167 | 168 | return listener 169 | } 170 | -------------------------------------------------------------------------------- /utils/migrations.ts: -------------------------------------------------------------------------------- 1 | import { storage } from '#imports' 2 | import { browser } from 'wxt/browser' 3 | import { UserSettings } from '@/utils/types' 4 | import { defaultSettings, setConfig } from '@/services/configService' 5 | import { errorLog, debugLog } from '.' 6 | 7 | // #region Update settings from v1.x.x to v2.0.0 8 | interface LegacyName { 9 | first: string 10 | middle: string 11 | last: string 12 | } 13 | 14 | interface LegacySettings { 15 | name: LegacyName | null 16 | deadname: LegacyName[] | null 17 | enabled: boolean | null 18 | stealthMode: boolean | null 19 | highlight: boolean | null 20 | } 21 | 22 | async function getLegacySettings(): Promise { 23 | try { 24 | // In the legacy version, each setting was stored in its own key 25 | // All settings were stored in sync storage 26 | const enabled = await storage.getItem('sync:enabled') 27 | const deadnames = await storage.getItem('sync:deadname') 28 | const name = await storage.getItem('sync:name') 29 | const stealthMode = await storage.getItem('sync:stealthMode') 30 | const highlight = await storage.getItem('sync:highlight') 31 | 32 | // Return null if no legacy settings exist 33 | if (enabled === null && deadnames === null && name === null 34 | && stealthMode === null && highlight === null) { 35 | return null 36 | } 37 | 38 | return { 39 | name, 40 | deadname: deadnames, 41 | enabled, 42 | stealthMode, 43 | highlight, 44 | } 45 | } 46 | catch { 47 | errorLog('error getting legacy settings') 48 | return null 49 | } 50 | } 51 | 52 | function convertLegacyToNewFormat(legacy: LegacySettings): UserSettings { 53 | const newSettings: UserSettings = { 54 | ...defaultSettings, 55 | enabled: legacy.enabled ?? false, 56 | stealthMode: legacy.stealthMode ?? false, 57 | highlightReplacedNames: legacy.highlight ?? false, 58 | syncSettingsAcrossDevices: true, 59 | names: { 60 | first: [], 61 | middle: [], 62 | last: [], 63 | email: [], 64 | }, 65 | } 66 | 67 | // Convert each deadname to new format 68 | legacy.deadname?.forEach((deadname) => { 69 | // Only add non-empty names 70 | if (deadname.first && legacy.name?.first) { 71 | newSettings.names.first.push({ 72 | mappings: [deadname.first, legacy.name.first], 73 | }) 74 | } 75 | if (deadname.middle && legacy.name?.middle) { 76 | newSettings.names.middle.push({ 77 | mappings: [deadname.middle, legacy.name.middle], 78 | }) 79 | } 80 | if (deadname.last && legacy.name?.last) { 81 | newSettings.names.last.push({ 82 | mappings: [deadname.last, legacy.name.last], 83 | }) 84 | } 85 | }) 86 | 87 | return newSettings 88 | } 89 | 90 | function deduplicateNameMappings(settings: UserSettings): UserSettings { 91 | const deduped = { ...settings } 92 | 93 | // Deduplicate each name type (first, middle, last) 94 | for (const type of ['first', 'middle', 'last'] as const) { 95 | // Convert to string for comparison and filter duplicates 96 | const seen = new Set() 97 | deduped.names[type] = settings.names[type].filter((mapping) => { 98 | const key = mapping.mappings.join('|') 99 | if (seen.has(key)) return false 100 | seen.add(key) 101 | return true 102 | }) 103 | } 104 | 105 | return deduped 106 | } 107 | 108 | /** 109 | * Removes self mappings from the user settings. 110 | * Filters out any NameEntry from each name type in settings.names 111 | * where the dead name (mappings[0]) is identical to the live name (mappings[1]). 112 | * @param settings - UserSettings containing the names property 113 | * @returns UserSettings with self mappings removed from names 114 | */ 115 | export function removeSelfMappings(settings: UserSettings): UserSettings { 116 | const updatedSettings = { ...settings } 117 | 118 | // Remove self mappings for each name type (first, middle, last) 119 | for (const type of ['first', 'middle', 'last'] as const) { 120 | // Filter out mappings where the dead name is the same as the live name 121 | updatedSettings.names[type] = settings.names[type].filter(mapping => mapping.mappings[0] !== mapping.mappings[1]) 122 | } 123 | 124 | return updatedSettings 125 | } 126 | 127 | /** 128 | * Removes recursive mappings from the user settings. 129 | * Filters out any NameEntry from each name type in settings.names 130 | * where the live name (mappings[1]) is identical to the dead name (other.mappings[0]) 131 | * in any other mapping (self-mappings are ignored). 132 | * @param settings - UserSettings containing the names property 133 | * @returns UserSettings with recursive mappings removed from names 134 | */ 135 | export function removeRecursiveMappings(settings: UserSettings): UserSettings { 136 | const updatedSettings = { ...settings } 137 | 138 | // Remove recursive mappings for each name type (first, middle, last) 139 | for (const type of ['first', 'middle', 'last'] as const) { 140 | updatedSettings.names[type] = settings.names[type].filter((candidate, idx, arr) => { 141 | const candidateLive = candidate.mappings[1] 142 | // Check if any other mapping (ignoring self) has its dead name equal to candidate's live name 143 | // and that other mapping is not a self mapping. 144 | const isRecursive = arr.some((other, otherIdx) => { 145 | if (otherIdx === idx) return false // skip the same mapping 146 | return other.mappings[0] === candidateLive && other.mappings[0] !== other.mappings[1] 147 | }) 148 | return !isRecursive 149 | }) 150 | } 151 | 152 | return updatedSettings 153 | } 154 | 155 | export async function checkAndMigrateSettings(): Promise { 156 | const legacySettings = await getLegacySettings() 157 | 158 | if (!legacySettings) { 159 | await debugLog('no legacy settings found') 160 | return 161 | } 162 | 163 | await debugLog('legacy settings detected, starting migration') 164 | 165 | // Convert and save to new format 166 | const newSettings = convertLegacyToNewFormat(legacySettings) 167 | 168 | await debugLog('settings successfully migrated to v2.0.0 format', newSettings) 169 | 170 | try { 171 | await setConfig(newSettings) 172 | await debugLog('settings successfully saved to sync storage') 173 | } 174 | catch (error) { 175 | const typedError = error as Error 176 | if (typedError.message.includes('Duplicate deadnames found')) { 177 | // Deduplicate and try saving again 178 | const dedupedSettings = deduplicateNameMappings(newSettings) 179 | await setConfig(dedupedSettings) 180 | await debugLog('deduped settings successfully saved to sync storage') 181 | } 182 | else if (typedError.message.includes('Self mappings found')) { 183 | // Remove self mappings and try saving again 184 | const removedSelfMappings = removeSelfMappings(newSettings) 185 | await setConfig(removedSelfMappings) 186 | await debugLog('removed self mappings successfully saved to sync storage') 187 | } 188 | else if (typedError.message.includes('Recursive mappings found')) { 189 | // Remove recursive mappings and try saving again 190 | const removedRecursiveMappings = removeRecursiveMappings(newSettings) 191 | await setConfig(removedRecursiveMappings) 192 | await debugLog('removed recursive mappings successfully saved to sync storage') 193 | } 194 | else { 195 | errorLog('error saving settings', error) 196 | throw error 197 | } 198 | } 199 | 200 | // Clean up old storage 201 | await storage.removeItem('sync:enabled') 202 | await storage.removeItem('sync:deadname') 203 | await storage.removeItem('sync:name') 204 | await storage.removeItem('sync:stealthMode') 205 | await storage.removeItem('sync:highlight') 206 | } 207 | // #endregion 208 | 209 | export async function createStealthUpgradeNotification(version: string): Promise { 210 | // If stealth mode is enabled, set badge text and create notification system 211 | // See https://wxt.dev/guide/essentials/extension-apis.html#feature-detection 212 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 213 | await (browser.action ?? browser.browserAction).setBadgeText({ text: version }) 214 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 215 | await (browser.action ?? browser.browserAction).setBadgeBackgroundColor({ color: '#8B5CF6' }) 216 | 217 | // Store update info in storage so that the popup can show a toast without messaging. 218 | await storage.setItem('local:versionToShowUpgradeNotification', version) 219 | } 220 | 221 | export async function checkForStealthUpgradeNotification(): Promise { 222 | const result = await storage.getItem('local:versionToShowUpgradeNotification') 223 | return result 224 | } 225 | 226 | export async function clearStealthUpgradeNotification(): Promise { 227 | await storage.removeItem('local:versionToShowUpgradeNotification') 228 | // See https://wxt.dev/guide/essentials/extension-apis.html#feature-detection 229 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 230 | await (browser.action ?? browser.browserAction).setBadgeText({ text: '' }) 231 | } 232 | -------------------------------------------------------------------------------- /utils/types.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot' 2 | import { validateNoDuplicateDeadnames, validateNoRecursiveMappings, validateNoSelfMappings } from './validations' 3 | 4 | const trimmedString = v.pipe(v.string(), v.trim(), v.nonEmpty()) 5 | export const trimmedEmail = v.pipe(trimmedString, v.email()) 6 | const NameTuple = v.tuple([trimmedString, trimmedString]) 7 | const EmailTuple = v.tuple([trimmedEmail, trimmedEmail]) 8 | 9 | /** 10 | * Represents a mapping of proper names to deadnames. 11 | * Each tuple represents one mapping of a proper name to a deadname. 12 | * @example 13 | * ["jack", "jackie"] // deadname, properName 14 | */ 15 | export type NameTuple = v.InferOutput 16 | 17 | const NameEntry = v.object({ 18 | mappings: NameTuple, 19 | }) 20 | const EmailEntry = v.object({ 21 | mappings: EmailTuple, 22 | }) 23 | 24 | export type NameEntry = v.InferOutput 25 | export type EmailEntry = v.InferOutput 26 | 27 | export interface Names { 28 | first: NameEntry[] 29 | middle: NameEntry[] 30 | last: NameEntry[] 31 | email: EmailEntry[] 32 | } 33 | 34 | export const themes = [{ 35 | label: 'Trans', 36 | value: 'trans', 37 | }, { 38 | label: 'Non-Binary', 39 | value: 'non-binary', 40 | }, { 41 | label: 'High Contrast (Yellow)', 42 | value: 'high-contrast', 43 | }] as const 44 | 45 | export const ToggleKeybinding = v.object({ 46 | key: v.string(), 47 | alt: v.boolean(), 48 | ctrl: v.boolean(), 49 | shift: v.boolean(), 50 | meta: v.boolean(), 51 | }) 52 | 53 | export const UserSettings = v.object({ 54 | names: v.pipe( 55 | v.object({ 56 | first: v.array(NameEntry), 57 | middle: v.array(NameEntry), 58 | last: v.array(NameEntry), 59 | email: v.array(EmailEntry), 60 | }), 61 | v.check(validateNoDuplicateDeadnames, 'Duplicate deadnames found'), 62 | v.check(validateNoSelfMappings, 'Self mappings found'), 63 | v.check(validateNoRecursiveMappings, 'Recursive mappings found'), 64 | ), 65 | enabled: v.boolean(), 66 | stealthMode: v.boolean(), 67 | hideDebugInfo: v.boolean(), 68 | blockContentBeforeDone: v.boolean(), 69 | highlightReplacedNames: v.boolean(), 70 | syncSettingsAcrossDevices: v.boolean(), 71 | theme: v.union(themes.map(x => v.literal(x.value))), 72 | toggleKeybinding: v.union([v.null(), ToggleKeybinding]), 73 | }) 74 | 75 | export type UserSettings = v.InferOutput 76 | 77 | export type ReplacementsMap = Map 78 | -------------------------------------------------------------------------------- /utils/validations.ts: -------------------------------------------------------------------------------- 1 | import type { NameEntry, Names, NameTuple } from './types' 2 | import { filterEmptyNamePairs } from './index' 3 | /** 4 | * Checks if there are any duplicate deadnames across all name categories (first, middle, last) 5 | * @param nameMappings - Object containing arrays of NameEntry for each name category 6 | * @returns boolean - true if no duplicates found, false if duplicates exist 7 | */ 8 | export function validateNoDuplicateDeadnames(nameMappings: Names) { 9 | const filteredNameMappings = filterEmptyNamePairs(nameMappings) 10 | 11 | const deadnames = (Object.values(filteredNameMappings).flat() as NameEntry[]).flatMap(({ mappings }) => mappings[0].toLowerCase()) 12 | const uniqueDeadnames = new Set(deadnames) 13 | return uniqueDeadnames.size === deadnames.length 14 | } 15 | 16 | /** 17 | * Checks if there are any self mappings (e.g. "John" -> "John") 18 | * @param nameMappings - Object containing arrays of NameEntry for each name category 19 | * @returns boolean - true if no self mappings found, false if self mappings exist 20 | */ 21 | export function validateNoSelfMappings(nameMappings: Names) { 22 | const filteredNameMappings = filterEmptyNamePairs(nameMappings) 23 | 24 | const nameTuples: NameTuple[] = (Object.values(filteredNameMappings).flat() as NameEntry[]).map(({ mappings }) => mappings) 25 | const hasSelfMapping = nameTuples.some(item => item[0].toLowerCase() === item[1].toLowerCase()) 26 | return !hasSelfMapping 27 | } 28 | 29 | /** 30 | * Checks if there are any recursive mappings (e.g. "John" -> "Doe" and "Doe" -> "John") 31 | * @param nameMappings - Object containing arrays of NameEntry for each name category 32 | * @returns boolean - true if no recursive mappings found, false if recursive mappings exist 33 | */ 34 | export function validateNoRecursiveMappings(nameMappings: Names) { 35 | const filteredNameMappings = filterEmptyNamePairs(nameMappings) 36 | const nameEntries = Object.values(filteredNameMappings).flat() as NameEntry[] 37 | 38 | // Create a Map of lowercase live names to their indices 39 | const liveNameMap = new Map( 40 | nameEntries.map((entry, index) => [entry.mappings[1].toLowerCase(), index]), 41 | ) 42 | 43 | // Check if any dead name exists as a live name (excluding self) 44 | const recursiveMappings = nameEntries.some((entry, i) => { 45 | const matchingLiveNameIndex = liveNameMap.get(entry.mappings[0].toLowerCase()) 46 | return matchingLiveNameIndex !== undefined && matchingLiveNameIndex !== i 47 | }) 48 | 49 | return !recursiveMappings 50 | } 51 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | import { WxtVitest } from 'wxt/testing' 3 | 4 | export default defineConfig({ 5 | // @ts-expect-error -- version mismatches, not a big deal. Should be resolved soon 6 | plugins: [WxtVitest()], 7 | }) 8 | -------------------------------------------------------------------------------- /wxt.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'wxt' 2 | 3 | // See https://wxt.dev/api/config.html 4 | export default defineConfig({ 5 | imports: { 6 | }, 7 | modules: ['@wxt-dev/unocss', '@wxt-dev/module-svelte'], 8 | vite: () => ({ 9 | resolve: { 10 | conditions: ['browser'], 11 | }, 12 | }), 13 | manifest: input => ({ 14 | key: input.browser === 'chrome' 15 | ? 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAgttEH3/mr2y2ey2XaLPZV0y3qLABXLIt3ro/eaJTex41I3Zvy76RLQbLhgQoEkU/TfkE9OT3rif2Sc1WtndFf3GMmgmzdUwNdS1DfJOjlavNLfvei0+XJQod/s2tSR9crhSyU7IjvW6niZVMnewtol3stf7CZZvz81zaUYz1XtLQWLI2D52FZUWiZEvtF1/pRmUOJBRsLjuWPNdEEPnby5NJ+B9tbKhvQ6SaXfiOT+pmHOcunHdsL1Ys3dlww3oERX7hDCSv1ZuzjzERcbqHEo5cAA916HQ+ugUU1Fi4/k1f9xXRn8TgCvno79/pmRO1WFDtnDW6/p1LdQDAmZnunwIDAQAB' 16 | : undefined, 17 | browser_specific_settings: { 18 | // ID is static and can't be changed 19 | gecko: { 20 | id: 'deadname-remover@willhaycode.com', 21 | strict_min_version: '58.0', 22 | }, 23 | }, 24 | icons: { 25 | 16: 'icon/trans16.png', 26 | 32: 'icon/trans32.png', 27 | 48: 'icon/trans48.png', 28 | 128: 'icon/trans128.png', 29 | }, 30 | permissions: ['storage'], 31 | action: { 32 | default_icon: 'icon/trans16.png', 33 | default_popup: 'popup.html', 34 | default_title: 'Deadname Remover Settings', 35 | }, 36 | author: input.browser === 'chrome' 37 | ? { email: 'hi@arimgibson.com' } 38 | : 'Ari Gibson', 39 | homepage_url: 'https://github.com/arimgibson/Deadname-Remover', 40 | offline_enabled: input.browser === 'chrome' ? true : undefined, 41 | }), 42 | }) 43 | --------------------------------------------------------------------------------