To apply a replacement map without changing newly inserted text, search only the original string and build a separate result. This guide uses JavaScript in Safari on a Mac to update a short passage with literal, predictable replacements.

Suppose a desktop editing task calls for changing blue to green and green to gold. An original blue should become green once. An original green should become gold. The following approach handles that distinction, overlapping phrases, quoted text and replacement values containing punctuation.

Why does a replacement loop change text twice?

A loop that applies each rule to the previous rule’s output creates a cascade. With those two color rules, the first operation inserts green; the second operation then finds that newly inserted word and changes it to gold. Reordering rules may fix one example, but it does not define dependable behavior for every map.

JavaScript’s replace() returns a new string and leaves its input unchanged. Cascading happens when subsequent calls receive the returned string. The task therefore needs a clear rule: every match must come from the original input. See MDN’s String.prototype.replace() documentation.

For the authored input The blue cup is beside the green cup., the intended result is The green cup is beside the gold cup. Both original color words change, while the inserted green remains untouched. This is the transformation the implementation below performs.

What should happen when replacement keys overlap?

Preventing cascades leaves another decision: which key wins when several match the same source text? For this editing task, use the following explicit policy. These are design choices for the example, rather than automatic rules supplied by JavaScript.

  • Scan the original input from left to right.
  • At each position, choose the longest matching key.
  • Consume that entire source match before continuing.
  • Insert its mapped value literally, without searching that value.
  • Copy unmatched text exactly.
  • Match case and spacing exactly.

For example, if both blue and blue vase are keys, the phrase blue vase wins at the same starting position. A match that starts earlier takes precedence over a longer match starting later. Once a source span is consumed, an overlapping match cannot reuse it.

This policy suits a small set of literal phrase edits: specific phrases take priority over their shorter prefixes. It also makes the output independent of the order in which differently sized keys were entered. If your task requires a different overlap policy, decide that before applying the map.

How can you run the transformation on a Mac?

Safari provides a JavaScript console, so this example needs no additional application installation. Enable Web Inspector through Safari → Settings… → Advanced → Show features for web developers. Then open Develop → Show Web Inspector, or press Option-Command-I. These steps follow WebKit’s Web Inspector setup documentation.

Open a blank page by entering about:blank in Safari’s address field, then open its inspector and select the Console tab. Paste the complete program below into the console prompt and evaluate it. WebKit documents that the prompt evaluates JavaScript and displays its results in the console. See Web Inspector Interface.

The example is a complete browser program with synthetic text and no imports. Its outer function keeps its declarations local, allowing you to paste and run the block again after editing the sample.

(() => {
  function applyReplacementMap(input, replacements) {
    if (typeof input !== "string" || !(replacements instanceof Map)) {
      throw new TypeError("Use a string and a Map.");
    }

    const entries = [...replacements];

    for (const [key, value] of entries) {
      if (
        typeof key !== "string" ||
        key.length === 0 ||
        typeof value !== "string"
      ) {
        throw new TypeError(
          "Keys must be nonempty strings; values must be strings."
        );
      }
    }

    entries.sort((a, b) => b[0].length - a[0].length);

    const output = [];
    let position = 0;

    while (position < input.length) {
      const entry = entries.find(([key]) =>
        input.startsWith(key, position)
      );

      if (entry) {
        output.push(entry[1]);
        position += entry[0].length;
      } else {
        output.push(input.slice(position, position + 1));
        position += 1;
      }
    }

    return output.join("");
  }

  const replacements = new Map([
    ["blue", "green"],
    ["green", "gold"],
    ["blue vase", "green bowl"],
    ["[tag]", "$&"],
    ["(draft)", ""]
  ]);

  const input =
    'The blue vase is beside the green cup. Label: "[tag]". Note: (draft)';

  const result = applyReplacementMap(input, replacements);
  console.log(result);
})();

The logged text is shown below. One trailing space remains after Note: because the rule removes only (draft), preserving the preceding space.

The green bowl is beside the gold cup. Label: "$&". Note: 

To adapt the example, edit input and the pairs inside new Map(...). Each pair contains the exact source string followed by its replacement. Use one entry per key: JavaScript maps hold unique keys, so repeating a key updates its value rather than creating another competing rule. See MDN’s Map reference.

Why inserted text stays untouched

The cursor named position advances only through input. Matching uses input.startsWith(key, position), which checks for a string at a specified position and is case-sensitive, as documented in MDN’s startsWith() reference. The output array is never used as a search target.

When no rule matches, slice(position, position + 1) copies the next source unit. The second slice index is exclusive, and slicing does not modify the original string. Those documented semantics let the function preserve unmatched content while building its result separately. See MDN’s slice() reference.

The example inserts green bowl for blue vase. Although that inserted phrase contains another key, green, the function has already moved past the original phrase. It never examines green bowl. An original green later in the input still changes to gold.

How literal values and deletion work

This implementation appends replacement values directly. Consequently, $& remains $&, and square brackets or parentheses in keys are ordinary characters. An empty replacement string deletes the matched source span. No regular expression or replacement-template interpretation occurs in this function.

Empty keys are rejected because they consume no source text; allowing one would prevent the matched branch from advancing. Nonstring values are also rejected before transformation begins, making a mistaken value visible instead of silently converting or omitting it. An empty map returns the input unchanged, and an empty input returns an empty string.

How should you check the result before copying it?

Use small cases that distinguish the behavior you want. The following expected results are derived from the implementation above. Unless stated otherwise, they use its sample map.

Check Input Expected result
Inserted text stays unchanged blue green green gold
Longer phrase wins blue vase green bowl
Replacement punctuation stays literal [tag] $&
Matching respects case Blue Blue
Matching includes substrings blueprint greenprint
Quotes remain present "blue" "green"
Empty map blue blue

The replacement function and these behaviors were checked in Node.js 20.19.2, together with deletion, empty input, overlapping matches and invalid-map rejection. That verifies the JavaScript transformation in that runtime; the Safari interface steps come from WebKit’s documentation.

Pay particular attention to the substring check. This function does not infer word boundaries or repair grammar. A key can match inside a longer word, and removing text can leave extra spaces. For a phrase-editing task, inspect those surrounding characters and make the map as specific as the intended change requires.

Also distinguish one application from repeated application. Running the function once on the original text avoids cascades within that run. Feeding its result back into the function starts a new transformation, so previously inserted words can then match. Keep the original input as the source when rerunning an edit.

Can a single JavaScript replace call do the same thing?

Yes. For a small, fixed set of simple keys, one global regular expression and a replacement callback can provide the same noncascading behavior. The ECMAScript specification gathers matches from the source string and then constructs the replacement result. See the specified regular-expression replacement algorithm.

This separate, complete example handles the two fixed color keys:

(() => {
  const replacements = new Map([
    ["blue", "green"],
    ["green", "gold"]
  ]);

  const input = "The blue cup is beside the green cup.";
  const result = input.replace(
    /blue|green/g,
    (word) => replacements.get(word)
  );

  console.log(result);
  // The green cup is beside the gold cup.
})();

The g flag makes the expression replace all matches. Returning values from a callback also avoids replacement-string patterns such as $&, which otherwise means the matched substring. MDN explicitly documents both behaviors in its replace() reference.

For a changing map containing punctuation, constructing a regular expression introduces additional requirements. Keys must be escaped as literal patterns, and overlapping alternatives need an explicit priority. JavaScript provides RegExp.escape() for literal pattern construction, but MDN notes that older browsers may lack it. See MDN’s RegExp.escape() documentation.

For the desktop phrase-editing task here, the literal scanner keeps those decisions visible: it reads the original text, chooses the longest key at each position and appends each replacement unchanged. Run it against the original passage, inspect the resulting wording and spacing, then copy the finished text into your document.

Sources