Skip to content

Commit d4dac0d

Browse files
authored
Text Replacer example (#1718)
* Text Replacer example * Addressing feedback from PR #612 * Fix issues identified in #1718 review * Add more icon options to the manifest * Add README * Update copyright and license headers * Iterating on feedback in #1718 * Simplify regex creation logic * Fix ESLint errors and warnings
1 parent e715a8f commit d4dac0d

13 files changed

Lines changed: 499 additions & 0 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Text Replacer
2+
3+
This sample demonstrates how an extension can replace text on a page in response to user invocation.
4+
5+
## Overview
6+
7+
Users can use the extension's popup to define up to 5 pairs of text replacements. Text is replaced when the user clicks the "Replace" button in the popup, when they select the "Replace text" context menu option on a page, or when they use the extension's keyboard shortcut (command).
8+
9+
## Compatibility
10+
11+
`"minimum_chrome_version"` is set to 148 because that version adds support for returning a promise from an `onMessage` event handler ([docs](https://developer.chrome.com/docs/extensions/develop/concepts/messaging#:~:text=call%20it%20later.-,Return%20a%20promise,-From%20Chrome%20148)) to respond to a message asynchronously.
12+
13+
## Running this extension
14+
15+
1. Clone this repository.
16+
2. Load this directory in Chrome as an [unpacked extension](https://developer.chrome.com/docs/extensions/mv3/getstarted/development-basics/#load-unpacked).
17+
3. Navigate to any page (make sure that the URL doesn't start with `chrome://`).
18+
4. Click the extension icon.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
const REPLACE_TEXT_MENUITEM_ID = 'replace-text-menuitem';
16+
const REPLACE_TEXT_COMMAND_ID = 'replace-text-command';
17+
const GET_REPLACEMENTS_MESSAGE_ID = 'get-replacements';
18+
const SET_REPLACEMENTS_MESSAGE_ID = 'set-replacements';
19+
20+
chrome.runtime.onInstalled.addListener(async () => {
21+
// Remove any previously registered context menus to avoid conflicts
22+
await chrome.contextMenus.removeAll();
23+
24+
// Register the new set of context menus
25+
await chrome.contextMenus.create({
26+
id: REPLACE_TEXT_MENUITEM_ID,
27+
title: 'Replace text'
28+
});
29+
});
30+
31+
chrome.commands.onCommand.addListener((command, tab) => {
32+
if (command == REPLACE_TEXT_COMMAND_ID) {
33+
replaceText(tab.id);
34+
} else {
35+
throw new Error(`Unknown command executed with ID "${command}"`);
36+
}
37+
});
38+
39+
chrome.contextMenus.onClicked.addListener((info, tab) => {
40+
if (info.menuItemId == REPLACE_TEXT_MENUITEM_ID) {
41+
replaceText(tab.id);
42+
} else {
43+
throw new Error(
44+
`Unknown context menu option clicked with ID "${info.menuItemId}"`
45+
);
46+
}
47+
});
48+
49+
// Route all storage reads/writes through the background so we have a single
50+
// source of truth
51+
chrome.runtime.onMessage.addListener((message) => {
52+
switch (message.id) {
53+
case GET_REPLACEMENTS_MESSAGE_ID:
54+
// Fall back to an empty array 'patterns' is not set
55+
return chrome.storage.sync.get({ patterns: [] });
56+
57+
case SET_REPLACEMENTS_MESSAGE_ID:
58+
return chrome.storage.sync.set({ patterns: message.data });
59+
60+
default:
61+
throw new Error(`Unknown message received with ID "${message.id}"`);
62+
}
63+
});
64+
65+
function replaceText(tabId) {
66+
chrome.scripting.executeScript({
67+
target: { tabId },
68+
files: ['content.js']
69+
});
70+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Wrap the in an immediately invoked function expression (IIFE) in order to
16+
// prevent local variables from polluting global scope
17+
(function () {
18+
const GET_REPLACEMENTS_MESSAGE_ID = 'get-replacements';
19+
20+
function buildReplacementRegex(source = []) {
21+
const output = source
22+
.filter(([find] = []) => find)
23+
.map(([find, replace]) => {
24+
const sanitizedMatch = escapeRegExp(find);
25+
const findExp = new RegExp(`\\b${sanitizedMatch}\\b`, 'gi');
26+
return [findExp, replace];
27+
});
28+
return output;
29+
}
30+
31+
// For example purposes only. This may not cover all special characters used
32+
// in regular repressions.
33+
const REGEXP_SPECIAL_CHARACTERS = /[.(){}^$*+?[\]\\]/g;
34+
35+
/** Sanitize user input to prevent unexpected behavior during RegExp execution */
36+
function escapeRegExp(pattern) {
37+
return pattern.replace(REGEXP_SPECIAL_CHARACTERS, '\\$&');
38+
}
39+
40+
/** Iterate through all text nodes and replace */
41+
function replaceText(replacements) {
42+
const nodeIterator = document.createNodeIterator(
43+
document.body,
44+
NodeFilter.SHOW_TEXT
45+
);
46+
47+
let node;
48+
while ((node = nodeIterator.nextNode())) {
49+
for (let [find, replace] of replacements) {
50+
// Guard against nullish replacement values
51+
replace ??= '';
52+
node.nodeValue = node.nodeValue.replace(find, replace);
53+
}
54+
}
55+
}
56+
57+
// Get the patterns to replace from storage, then build a regex from them and
58+
// replace all text on the page.
59+
chrome.runtime
60+
.sendMessage({ id: GET_REPLACEMENTS_MESSAGE_ID })
61+
.then((data) => {
62+
const replacementPatterns = buildReplacementRegex(data.patterns);
63+
replaceText(replacementPatterns);
64+
});
65+
})();
4.09 KB
Loading
490 Bytes
Loading
764 Bytes
Loading
1.02 KB
Loading
1.61 KB
Loading
2.1 KB
Loading
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"name": "Text Replacer",
3+
"version": "1.0.0",
4+
"manifest_version": 3,
5+
"description": "Replace any line of text on a page with another line of text.",
6+
"minimum_chrome_version": "148",
7+
"icons": {
8+
"16": "icons/icon16.png",
9+
"24": "icons/icon24.png",
10+
"32": "icons/icon32.png",
11+
"48": "icons/icon48.png",
12+
"64": "icons/icon64.png",
13+
"128": "icons/icon128.png"
14+
},
15+
"action": {
16+
"default_title": "Manage text replacements",
17+
"default_icon": {
18+
"16": "icons/icon16.png",
19+
"24": "icons/icon24.png",
20+
"32": "icons/icon32.png",
21+
"48": "icons/icon48.png",
22+
"64": "icons/icon64.png"
23+
},
24+
"default_popup": "popup.html"
25+
},
26+
"permissions": [
27+
"scripting",
28+
"activeTab",
29+
"storage",
30+
"commands",
31+
"contextMenus"
32+
],
33+
"background": {
34+
"service_worker": "background.js"
35+
},
36+
"commands": {
37+
"replace-text-command": {
38+
"description": "Replace text on the current page.",
39+
"suggested_key": {
40+
"default": "Alt+R",
41+
"mac": "MacCtrl+R"
42+
}
43+
}
44+
}
45+
}

0 commit comments

Comments
 (0)