Merge "Merge gr-linked-text into gr-markdown"
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index f2977dc..ed752e6 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -15,7 +15,7 @@
 import '../../shared/gr-change-star/gr-change-star';
 import '../../shared/gr-change-status/gr-change-status';
 import '../../shared/gr-editable-content/gr-editable-content';
-import '../../shared/gr-linked-text/gr-linked-text';
+import '../../shared/gr-markdown/gr-markdown';
 import '../../shared/gr-overlay/gr-overlay';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../gr-change-actions/gr-change-actions';
@@ -191,7 +191,6 @@
 
 const MIN_LINES_FOR_COMMIT_COLLAPSE = 18;
 
-const REVIEWERS_REGEX = /^(R|CC)=/gm;
 const MIN_CHECK_INTERVAL_SECS = 0;
 
 const REPLY_REFIT_DEBOUNCE_INTERVAL_MS = 500;
@@ -959,7 +958,7 @@
           /* Account for border and padding and rounding errors. */
           max-width: calc(72ch + 2px + 2 * var(--spacing-m) + 0.4px);
         }
-        .commitMessage gr-linked-text {
+        .commitMessage gr-markdown {
           word-break: break-word;
         }
         #commitMessageEditor {
@@ -1460,12 +1459,9 @@
                 .commitCollapsible=${this.computeCommitCollapsible()}
                 remove-zero-width-space=""
               >
-                <gr-linked-text
-                  pre=""
-                  .content=${this.latestCommitMessage}
-                  .config=${this.projectConfig?.commentlinks}
-                  remove-zero-width-space=""
-                ></gr-linked-text>
+                <gr-markdown
+                  .content=${this.latestCommitMessage ?? ''}
+                ></gr-markdown>
               </gr-editable-content>
             </div>
             <h3 class="assistive-tech-only">Comments and Checks Summary</h3>
@@ -1824,7 +1820,7 @@
           return;
         }
 
-        this.latestCommitMessage = this.prepareCommitMsgForLinkify(message);
+        this.latestCommitMessage = message;
         this.editingCommitMessage = false;
         this.reloadWindow();
       })
@@ -2674,14 +2670,6 @@
     this.changeViewAriaHidden = true;
   }
 
-  // Private but used in tests.
-  prepareCommitMsgForLinkify(msg: string) {
-    // TODO(wyatta) switch linkify sequence, see issue 5526.
-    // This is a zero-with space. It is added to prevent the linkify library
-    // from including R= or CC= as part of the email address.
-    return msg.replace(REVIEWERS_REGEX, '$1=\u200B');
-  }
-
   /**
    * Utility function to make the necessary modifications to a change in the
    * case an edit exists.
@@ -2811,9 +2799,7 @@
       throw new Error('Could not find latest Revision Sha');
     const currentRevision = this.change.revisions[latestRevisionSha];
     if (currentRevision.commit && currentRevision.commit.message) {
-      this.latestCommitMessage = this.prepareCommitMsgForLinkify(
-        currentRevision.commit.message
-      );
+      this.latestCommitMessage = currentRevision.commit.message;
     } else {
       this.latestCommitMessage = null;
     }
@@ -2866,9 +2852,7 @@
       .getChangeCommitInfo(this.changeNum, lastpatchNum)
       .then(commitInfo => {
         if (!commitInfo) return;
-        this.latestCommitMessage = this.prepareCommitMsgForLinkify(
-          commitInfo.message
-        );
+        this.latestCommitMessage = commitInfo.message;
       });
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index 0ae2e67..49248ea 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -433,9 +433,7 @@
                         id="commitMessageEditor"
                         remove-zero-width-space=""
                       >
-                        <gr-linked-text pre="" remove-zero-width-space="">
-                          <span id="output" slot="insert"> </span>
-                        </gr-linked-text>
+                        <gr-markdown></gr-markdown>
                       </gr-editable-content>
                     </div>
                     <h3 class="assistive-tech-only">
@@ -1409,20 +1407,6 @@
     assert.isTrue(overlayOpenStub.called);
   });
 
-  test('prepareCommitMsgForLinkify', () => {
-    let commitMessage = 'R=test@google.com';
-    let result = element.prepareCommitMsgForLinkify(commitMessage);
-    assert.equal(result, 'R=\u200Btest@google.com');
-
-    commitMessage = 'R=test@google.com\nR=test@google.com';
-    result = element.prepareCommitMsgForLinkify(commitMessage);
-    assert.equal(result, 'R=\u200Btest@google.com\nR=\u200Btest@google.com');
-
-    commitMessage = 'CC=test@google.com';
-    result = element.prepareCommitMsgForLinkify(commitMessage);
-    assert.equal(result, 'CC=\u200Btest@google.com');
-  });
-
   test('_isSubmitEnabled', () => {
     assert.isFalse(element.isSubmitEnabled());
     element.currentRevisionActions = {submit: {}};
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index ebb07e40..731f227 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -12,7 +12,6 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-cursor-manager/gr-cursor-manager';
 import '../../shared/gr-icon/gr-icon';
-import '../../shared/gr-linked-text/gr-linked-text';
 import '../../shared/gr-select/gr-select';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
index d6a4d94..8b651f1 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
@@ -370,10 +370,7 @@
       content = this.content || '';
     }
 
-    // TODO(wyatta) switch linkify sequence, see issue 5526.
-    this.newContent = this.removeZeroWidthSpace
-      ? content.replace(/^R=\u200B/gm, 'R=')
-      : content;
+    this.newContent = content;
   }
 
   computeSaveDisabled(): boolean {
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
index 6389955..fec347c6 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
@@ -140,25 +140,6 @@
     assert.equal(element.newContent, 'stale content');
   });
 
-  test('zero width spaces are removed properly', async () => {
-    element.removeZeroWidthSpace = true;
-    element.content = 'R=\u200Btest@google.com';
-
-    // Needed because contentChanged resets newContent
-    // We want contentChanged observer to finish before editingChanged is
-    // called
-
-    await element.updateComplete;
-
-    element.editing = true;
-
-    // editingChanged updates newContent so wait for it's observer
-    // to finish
-    await element.updateComplete;
-
-    assert.equal(element.newContent, 'R=test@google.com');
-  });
-
   suite('editing', () => {
     setup(async () => {
       element.content = 'current content';
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
index 23b4e81..11e7152 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
@@ -3,7 +3,6 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import '../gr-linked-text/gr-linked-text';
 import '../gr-markdown/gr-markdown';
 import {CommentLinks} from '../../../types/common';
 import {LitElement, css, html, TemplateResult} from 'lit';
@@ -91,7 +90,7 @@
         ul,
         code,
         blockquote,
-        gr-linked-text.pre {
+        gr-markdown.pre {
           margin: 0 0 var(--spacing-m) 0;
         }
         p,
@@ -103,7 +102,7 @@
         :host([noTrailingMargin]) p:last-child,
         :host([noTrailingMargin]) ul:last-child,
         :host([noTrailingMargin]) blockquote:last-child,
-        :host([noTrailingMargin]) gr-linked-text.pre:last-child {
+        :host([noTrailingMargin]) gr-markdown.pre:last-child {
           margin: 0;
         }
         blockquote {
@@ -142,7 +141,10 @@
     if (!this.content) return;
 
     if (this.flagsService.isEnabled(KnownExperimentId.RENDER_MARKDOWN)) {
-      return html`<gr-markdown .markdown=${this.content}></gr-markdown>`;
+      return html`<gr-markdown
+        .markdown=${true}
+        .content=${this.content}
+      ></gr-markdown>`;
     } else {
       const blocks = this._computeBlocks(this.content);
       return html`${blocks.map(block => this.renderBlock(block))}`;
@@ -354,14 +356,7 @@
   }
 
   private renderInlineText(content: string): TemplateResult {
-    return html`
-      <gr-linked-text
-        .config=${this.config}
-        content=${content}
-        pre
-        inline
-      ></gr-linked-text>
-    `;
+    return html`<gr-markdown .content=${content}></gr-markdown>`;
   }
 
   private renderLink(text: string, url: string): TemplateResult {
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
index 191886e..62cb7c8 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
@@ -103,9 +103,7 @@
       element,
       /* HTML */ `
         <p>
-          <gr-linked-text content="text " inline="" pre="">
-            <span id="output" slot="insert"> text </span>
-          </gr-linked-text>
+          <gr-markdown></gr-markdown>
           <span class="inline-code"> code </span>
         </p>
       `
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts
deleted file mode 100644
index 16a60e7..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts
+++ /dev/null
@@ -1,178 +0,0 @@
-/**
- * @license
- * Copyright 2015 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../../styles/shared-styles';
-import {GrLinkTextParser, LinkTextParserConfig} from './link-text-parser';
-import {LitElement, css, html, PropertyValues} from 'lit';
-import {customElement, property} from 'lit/decorators.js';
-import {assertIsDefined} from '../../../utils/common-util';
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-linked-text': GrLinkedText;
-  }
-}
-
-@customElement('gr-linked-text')
-export class GrLinkedText extends LitElement {
-  private outputElement?: HTMLSpanElement;
-
-  @property({type: Boolean, attribute: 'remove-zero-width-space'})
-  removeZeroWidthSpace?: boolean;
-
-  @property({type: String})
-  content = '';
-
-  @property({type: Boolean, attribute: true})
-  pre = false;
-
-  @property({type: Boolean, attribute: true})
-  disabled = false;
-
-  @property({type: Boolean, attribute: true})
-  inline = false;
-
-  @property({type: Object})
-  config?: LinkTextParserConfig;
-
-  static override get styles() {
-    return css`
-      :host {
-        display: block;
-      }
-      :host([inline]) {
-        display: inline;
-      }
-      :host([pre]) ::slotted(span) {
-        white-space: var(--linked-text-white-space, pre-wrap);
-        word-wrap: var(--linked-text-word-wrap, break-word);
-      }
-    `;
-  }
-
-  override render() {
-    return html`<slot name="insert"></slot>`;
-  }
-
-  // NOTE: LinkTextParser dynamically creates HTML fragments based on backend
-  // configuration commentLinks. These commentLinks can contain arbitrary HTML
-  // fragments. This means that arbitrary HTML needs to be injected into the
-  // DOM-tree, where this HTML is is controlled on the server-side in the
-  // server-configuration rather than by arbitrary users.
-  // To enable this injection of 'unsafe' HTML, LinkTextParser generates
-  // HTML fragments. Lit does not support inserting html fragments directly
-  // into its DOM-tree as it controls the DOM-tree that it generates.
-  // Therefore, to get around this we create a single element that we slot into
-  // the Lit-owned DOM.  This element will not be part of this LitElement as
-  // it's slotted in and thus can be modified on the fly by handleParseResult.
-  override firstUpdated(_changedProperties: PropertyValues): void {
-    this.outputElement = document.createElement('span');
-    this.outputElement.id = 'output';
-    this.outputElement.slot = 'insert';
-    this.append(this.outputElement);
-  }
-
-  override updated(changedProperties: PropertyValues): void {
-    if (changedProperties.has('content') || changedProperties.has('config')) {
-      this._contentOrConfigChanged();
-    } else if (changedProperties.has('disabled')) {
-      this.styleLinks();
-    }
-  }
-
-  /**
-   * Because either the source text or the linkification config has changed,
-   * the content should be re-parsed.
-   * Private but used in tests.
-   *
-   * @param content The raw, un-linkified source string to parse.
-   * @param config The server config specifying commentLink patterns
-   */
-  _contentOrConfigChanged() {
-    if (!this.config) {
-      assertIsDefined(this.outputElement);
-      this.outputElement.textContent = this.content;
-      return;
-    }
-
-    assertIsDefined(this.outputElement);
-    this.outputElement.textContent = '';
-    const parser = new GrLinkTextParser(
-      this.config,
-      (text: string | null, href: string | null, fragment?: DocumentFragment) =>
-        this.handleParseResult(text, href, fragment),
-      this.removeZeroWidthSpace
-    );
-    parser.parse(this.content);
-
-    // Ensure that external links originating from HTML commentlink configs
-    // open in a new tab. @see Issue 5567
-    // Ensure links to the same host originating from commentlink configs
-    // open in the same tab. When target is not set - default is _self
-    // @see Issue 4616
-    this.outputElement.querySelectorAll('a').forEach(anchor => {
-      if (anchor.hostname === window.location.hostname) {
-        anchor.removeAttribute('target');
-      } else {
-        anchor.setAttribute('target', '_blank');
-      }
-      anchor.setAttribute('rel', 'noopener');
-    });
-
-    this.styleLinks();
-  }
-
-  /**
-   * Styles the links based on whether gr-linked-text is disabled or not
-   */
-  private styleLinks() {
-    assertIsDefined(this.outputElement);
-    this.outputElement.querySelectorAll('a').forEach(anchor => {
-      anchor.setAttribute('style', this.computeLinkStyle());
-    });
-  }
-
-  private computeLinkStyle() {
-    if (this.disabled) {
-      return `
-        color: inherit;
-        text-decoration: none;
-        pointer-events: none;
-      `;
-    } else {
-      return 'color: var(--link-color)';
-    }
-  }
-
-  /**
-   * This method is called when the GrLikTextParser emits a partial result
-   * (used as the "callback" parameter). It will be called in either of two
-   * ways:
-   * - To create a link: when called with `text` and `href` arguments, a link
-   *   element should be created and attached to the resulting DOM.
-   * - To attach an arbitrary fragment: when called with only the `fragment`
-   *   argument, the fragment should be attached to the resulting DOM as is.
-   */
-  private handleParseResult(
-    text: string | null,
-    href: string | null,
-    fragment?: DocumentFragment
-  ) {
-    assertIsDefined(this.outputElement);
-    const output = this.outputElement;
-    if (href) {
-      const a = document.createElement('a');
-      a.setAttribute('href', href);
-      // GrLinkTextParser either pass text and href together or
-      // only DocumentFragment - see LinkTextParserCallback
-      a.textContent = text!;
-      a.target = '_blank';
-      a.setAttribute('rel', 'noopener');
-      output.appendChild(a);
-    } else if (fragment) {
-      output.appendChild(fragment);
-    }
-  }
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts
deleted file mode 100644
index 00e0313..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts
+++ /dev/null
@@ -1,471 +0,0 @@
-/**
- * @license
- * Copyright 2015 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../../test/common-test-setup';
-import './gr-linked-text';
-import {fixture, html, assert} from '@open-wc/testing';
-import {GrLinkedText} from './gr-linked-text';
-import {queryAndAssert} from '../../../test/test-utils';
-
-suite('gr-linked-text tests', () => {
-  let element: GrLinkedText;
-
-  let originalCanonicalPath: string | undefined;
-
-  setup(async () => {
-    originalCanonicalPath = window.CANONICAL_PATH;
-    element = await fixture<GrLinkedText>(html`
-      <gr-linked-text>
-        <div id="output"></div>
-      </gr-linked-text>
-    `);
-
-    element.config = {
-      ph: {
-        match: '([Bb]ug|[Ii]ssue)\\s*#?(\\d+)',
-        link: 'https://bugs.chromium.org/p/gerrit/issues/detail?id=$2',
-      },
-      prefixsameinlinkandpattern: {
-        match: '([Hh][Tt][Tt][Pp]example)\\s*#?(\\d+)',
-        link: 'https://bugs.chromium.org/p/gerrit/issues/detail?id=$2',
-      },
-      changeid: {
-        match: '(I[0-9a-f]{8,40})',
-        link: '#/q/$1',
-      },
-      changeid2: {
-        match: 'Change-Id: +(I[0-9a-f]{8,40})',
-        link: '#/q/$1',
-      },
-      googlesearch: {
-        match: 'google:(.+)',
-        link: 'https://bing.com/search?q=$1', // html should supersede link.
-        html: '<a href="https://google.com/search?q=$1">$1</a>',
-      },
-      hashedhtml: {
-        match: 'hash:(.+)',
-        html: '<a href="#/awesomesauce">$1</a>',
-      },
-      baseurl: {
-        match: 'test (.+)',
-        html: '<a href="/r/awesomesauce">$1</a>',
-      },
-      anotatstartwithbaseurl: {
-        match: 'a test (.+)',
-        html: '[Lookup: <a href="/r/awesomesauce">$1</a>]',
-      },
-      disabledconfig: {
-        match: 'foo:(.+)',
-        link: 'https://google.com/search?q=$1',
-        enabled: false,
-      },
-    };
-  });
-
-  teardown(() => {
-    window.CANONICAL_PATH = originalCanonicalPath;
-  });
-
-  test('render', async () => {
-    element.content =
-      'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
-    await element.updateComplete;
-    assert.lightDom.equal(
-      element,
-      /* HTML */ `
-        <div id="output"></div>
-        <span id="output" slot="insert">
-          <a
-            href="https://bugs.chromium.org/p/gerrit/issues/detail?id=3650"
-            rel="noopener"
-            style="color: var(--link-color)"
-            target="_blank"
-          >
-            https://bugs.chromium.org/p/gerrit/issues/detail?id=3650
-          </a>
-        </span>
-      `
-    );
-  });
-
-  test('URL pattern was parsed and linked.', async () => {
-    // Regular inline link.
-    const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
-    element.content = url;
-    await element.updateComplete;
-
-    const linkEl = queryAndAssert(element, 'span#output')
-      .childNodes[0] as HTMLAnchorElement;
-    assert.equal(linkEl.target, '_blank');
-    assert.equal(linkEl.rel, 'noopener');
-    assert.equal(linkEl.href, url);
-    assert.equal(linkEl.textContent, url);
-  });
-
-  test('Bug pattern was parsed and linked', async () => {
-    // "Issue/Bug" pattern.
-    element.content = 'Issue 3650';
-    await element.updateComplete;
-
-    let linkEl = queryAndAssert(element, 'span#output')
-      .childNodes[0] as HTMLAnchorElement;
-    const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
-    assert.equal(linkEl.target, '_blank');
-    assert.equal(linkEl.href, url);
-    assert.equal(linkEl.textContent, 'Issue 3650');
-
-    element.content = 'Bug 3650';
-    await element.updateComplete;
-
-    linkEl = queryAndAssert(element, 'span#output')
-      .childNodes[0] as HTMLAnchorElement;
-    assert.equal(linkEl.target, '_blank');
-    assert.equal(linkEl.rel, 'noopener');
-    assert.equal(linkEl.href, url);
-    assert.equal(linkEl.textContent, 'Bug 3650');
-  });
-
-  test('Pattern with same prefix as link was correctly parsed', async () => {
-    // Pattern starts with the same prefix (`http`) as the url.
-    element.content = 'httpexample 3650';
-    await element.updateComplete;
-
-    assert.equal(queryAndAssert(element, 'span#output').childNodes.length, 1);
-    const linkEl = queryAndAssert(element, 'span#output')
-      .childNodes[0] as HTMLAnchorElement;
-    const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
-    assert.equal(linkEl.target, '_blank');
-    assert.equal(linkEl.href, url);
-    assert.equal(linkEl.textContent, 'httpexample 3650');
-  });
-
-  test('Change-Id pattern was parsed and linked', async () => {
-    // "Change-Id:" pattern.
-    const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
-    const prefix = 'Change-Id: ';
-    element.content = prefix + changeID;
-    await element.updateComplete;
-
-    const textNode = queryAndAssert(element, 'span#output').childNodes[0];
-    const linkEl = queryAndAssert(element, 'span#output')
-      .childNodes[1] as HTMLAnchorElement;
-    assert.equal(textNode.textContent, prefix);
-    const url = '/q/' + changeID;
-    assert.isFalse(linkEl.hasAttribute('target'));
-    // Since url is a path, the host is added automatically.
-    assert.isTrue(linkEl.href.endsWith(url));
-    assert.equal(linkEl.textContent, changeID);
-  });
-
-  test('Change-Id pattern was parsed and linked with base url', async () => {
-    window.CANONICAL_PATH = '/r';
-
-    // "Change-Id:" pattern.
-    const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
-    const prefix = 'Change-Id: ';
-    element.content = prefix + changeID;
-    await element.updateComplete;
-
-    const textNode = queryAndAssert(element, 'span#output').childNodes[0];
-    const linkEl = queryAndAssert(element, 'span#output')
-      .childNodes[1] as HTMLAnchorElement;
-    assert.equal(textNode.textContent, prefix);
-    const url = '/r/q/' + changeID;
-    assert.isFalse(linkEl.hasAttribute('target'));
-    // Since url is a path, the host is added automatically.
-    assert.isTrue(linkEl.href.endsWith(url));
-    assert.equal(linkEl.textContent, changeID);
-  });
-
-  test('Multiple matches', async () => {
-    element.content = 'Issue 3650\nIssue 3450';
-    await element.updateComplete;
-
-    const linkEl1 = queryAndAssert(element, 'span#output')
-      .childNodes[0] as HTMLAnchorElement;
-    const linkEl2 = queryAndAssert(element, 'span#output')
-      .childNodes[2] as HTMLAnchorElement;
-
-    assert.equal(linkEl1.target, '_blank');
-    assert.equal(
-      linkEl1.href,
-      'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650'
-    );
-    assert.equal(linkEl1.textContent, 'Issue 3650');
-
-    assert.equal(linkEl2.target, '_blank');
-    assert.equal(
-      linkEl2.href,
-      'https://bugs.chromium.org/p/gerrit/issues/detail?id=3450'
-    );
-    assert.equal(linkEl2.textContent, 'Issue 3450');
-  });
-
-  test('Change-Id pattern parsed before bug pattern', async () => {
-    // "Change-Id:" pattern.
-    const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
-    const prefix = 'Change-Id: ';
-
-    // "Issue/Bug" pattern.
-    const bug = 'Issue 3650';
-
-    const changeUrl = '/q/' + changeID;
-    const bugUrl = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
-
-    element.content = prefix + changeID + bug;
-    await element.updateComplete;
-
-    const textNode = queryAndAssert(element, 'span#output').childNodes[0];
-    const changeLinkEl = queryAndAssert(element, 'span#output')
-      .childNodes[1] as HTMLAnchorElement;
-    const bugLinkEl = queryAndAssert(element, 'span#output')
-      .childNodes[2] as HTMLAnchorElement;
-
-    assert.equal(textNode.textContent, prefix);
-
-    assert.isFalse(changeLinkEl.hasAttribute('target'));
-    assert.isTrue(changeLinkEl.href.endsWith(changeUrl));
-    assert.equal(changeLinkEl.textContent, changeID);
-
-    assert.equal(bugLinkEl.target, '_blank');
-    assert.equal(bugLinkEl.href, bugUrl);
-    assert.equal(bugLinkEl.textContent, 'Issue 3650');
-  });
-
-  test('html field in link config', async () => {
-    element.content = 'google:do a barrel roll';
-    await element.updateComplete;
-
-    const linkEl = queryAndAssert(element, 'span#output')
-      .childNodes[0] as HTMLAnchorElement;
-    assert.equal(
-      linkEl.getAttribute('href'),
-      'https://google.com/search?q=do a barrel roll'
-    );
-    assert.equal(linkEl.textContent, 'do a barrel roll');
-  });
-
-  test('removing hash from links', async () => {
-    element.content = 'hash:foo';
-    await element.updateComplete;
-
-    const linkEl = queryAndAssert(element, 'span#output')
-      .childNodes[0] as HTMLAnchorElement;
-    assert.isTrue(linkEl.href.endsWith('/awesomesauce'));
-    assert.equal(linkEl.textContent, 'foo');
-  });
-
-  test('html with base url', async () => {
-    window.CANONICAL_PATH = '/r';
-
-    element.content = 'test foo';
-    await element.updateComplete;
-
-    const linkEl = queryAndAssert(element, 'span#output')
-      .childNodes[0] as HTMLAnchorElement;
-    assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
-    assert.equal(linkEl.textContent, 'foo');
-  });
-
-  test('a is not at start', async () => {
-    window.CANONICAL_PATH = '/r';
-
-    element.content = 'a test foo';
-    await element.updateComplete;
-
-    const linkEl = queryAndAssert(element, 'span#output')
-      .childNodes[1] as HTMLAnchorElement;
-    assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
-    assert.equal(linkEl.textContent, 'foo');
-  });
-
-  test('hash html with base url', async () => {
-    window.CANONICAL_PATH = '/r';
-
-    element.content = 'hash:foo';
-    await element.updateComplete;
-
-    const linkEl = queryAndAssert(element, 'span#output')
-      .childNodes[0] as HTMLAnchorElement;
-    assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
-    assert.equal(linkEl.textContent, 'foo');
-  });
-
-  test('disabled config', async () => {
-    element.content = 'foo:baz';
-    await element.updateComplete;
-
-    assert.equal(queryAndAssert(element, 'span#output').innerHTML, 'foo:baz');
-  });
-
-  test('R=email labels link correctly', async () => {
-    element.removeZeroWidthSpace = true;
-    element.content = 'R=\u200Btest@google.com';
-    await element.updateComplete;
-
-    assert.equal(
-      queryAndAssert(element, 'span#output').textContent,
-      'R=test@google.com'
-    );
-    assert.equal(
-      queryAndAssert(element, 'span#output').innerHTML.match(/(R=<a)/g)!.length,
-      1
-    );
-  });
-
-  test('CC=email labels link correctly', async () => {
-    element.removeZeroWidthSpace = true;
-    element.content = 'CC=\u200Btest@google.com';
-    await element.updateComplete;
-
-    assert.equal(
-      queryAndAssert(element, 'span#output').textContent,
-      'CC=test@google.com'
-    );
-    assert.equal(
-      queryAndAssert(element, 'span#output').innerHTML.match(/(CC=<a)/g)!
-        .length,
-      1
-    );
-  });
-
-  test('only {http,https,mailto} protocols are linkified', async () => {
-    element.content = 'xx mailto:test@google.com yy';
-    await element.updateComplete;
-
-    let links = queryAndAssert(element, 'span#output').querySelectorAll('a');
-    assert.equal(links.length, 1);
-    assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
-    assert.equal(links[0].innerHTML, 'mailto:test@google.com');
-
-    element.content = 'xx http://google.com yy';
-    await element.updateComplete;
-
-    links = queryAndAssert(element, 'span#output').querySelectorAll('a');
-    assert.equal(links.length, 1);
-    assert.equal(links[0].getAttribute('href'), 'http://google.com');
-    assert.equal(links[0].innerHTML, 'http://google.com');
-
-    element.content = 'xx https://google.com yy';
-    await element.updateComplete;
-
-    links = queryAndAssert(element, 'span#output').querySelectorAll('a');
-    assert.equal(links.length, 1);
-    assert.equal(links[0].getAttribute('href'), 'https://google.com');
-    assert.equal(links[0].innerHTML, 'https://google.com');
-
-    element.content = 'xx ssh://google.com yy';
-    await element.updateComplete;
-
-    links = queryAndAssert(element, 'span#output').querySelectorAll('a');
-    assert.equal(links.length, 0);
-
-    element.content = 'xx ftp://google.com yy';
-    await element.updateComplete;
-
-    links = queryAndAssert(element, 'span#output').querySelectorAll('a');
-    assert.equal(links.length, 0);
-  });
-
-  test('links without leading whitespace are linkified', async () => {
-    element.content = 'xx abcmailto:test@google.com yy';
-    await element.updateComplete;
-
-    assert.equal(
-      queryAndAssert(element, 'span#output').innerHTML.substr(0, 6),
-      'xx abc'
-    );
-    let links = queryAndAssert(element, 'span#output').querySelectorAll('a');
-    assert.equal(links.length, 1);
-    assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
-    assert.equal(links[0].innerHTML, 'mailto:test@google.com');
-
-    element.content = 'xx defhttp://google.com yy';
-    await element.updateComplete;
-
-    assert.equal(
-      queryAndAssert(element, 'span#output').innerHTML.substr(0, 6),
-      'xx def'
-    );
-    links = queryAndAssert(element, 'span#output').querySelectorAll('a');
-    assert.equal(links.length, 1);
-    assert.equal(links[0].getAttribute('href'), 'http://google.com');
-    assert.equal(links[0].innerHTML, 'http://google.com');
-
-    element.content = 'xx qwehttps://google.com yy';
-    await element.updateComplete;
-
-    assert.equal(
-      queryAndAssert(element, 'span#output').innerHTML.substr(0, 6),
-      'xx qwe'
-    );
-    links = queryAndAssert(element, 'span#output').querySelectorAll('a');
-    assert.equal(links.length, 1);
-    assert.equal(links[0].getAttribute('href'), 'https://google.com');
-    assert.equal(links[0].innerHTML, 'https://google.com');
-
-    // Non-latin character
-    element.content = 'xx абвhttps://google.com yy';
-    await element.updateComplete;
-
-    assert.equal(
-      queryAndAssert(element, 'span#output').innerHTML.substr(0, 6),
-      'xx абв'
-    );
-    links = queryAndAssert(element, 'span#output').querySelectorAll('a');
-    assert.equal(links.length, 1);
-    assert.equal(links[0].getAttribute('href'), 'https://google.com');
-    assert.equal(links[0].innerHTML, 'https://google.com');
-
-    element.content = 'xx ssh://google.com yy';
-    await element.updateComplete;
-
-    links = queryAndAssert(element, 'span#output').querySelectorAll('a');
-    assert.equal(links.length, 0);
-
-    element.content = 'xx ftp://google.com yy';
-    await element.updateComplete;
-
-    links = queryAndAssert(element, 'span#output').querySelectorAll('a');
-    assert.equal(links.length, 0);
-  });
-
-  test('overlapping links', async () => {
-    element.config = {
-      b1: {
-        match: '(B:\\s*)(\\d+)',
-        html: '$1<a href="ftp://foo/$2">$2</a>',
-      },
-      b2: {
-        match: '(B:\\s*\\d+\\s*,\\s*)(\\d+)',
-        html: '$1<a href="ftp://foo/$2">$2</a>',
-      },
-    };
-    element.content = '- B: 123, 45';
-    await element.updateComplete;
-
-    const links = element.querySelectorAll('a');
-
-    assert.equal(links.length, 2);
-    assert.equal(
-      queryAndAssert<HTMLSpanElement>(element, 'span').textContent,
-      '- B: 123, 45'
-    );
-
-    assert.equal(links[0].href, 'ftp://foo/123');
-    assert.equal(links[0].textContent, '123');
-
-    assert.equal(links[1].href, 'ftp://foo/45');
-    assert.equal(links[1].textContent, '45');
-  });
-
-  test('_contentOrConfigChanged called with config', async () => {
-    const contentConfigStub = sinon.stub(element, '_contentOrConfigChanged');
-    element.content = 'some text';
-    await element.updateComplete;
-
-    assert.isTrue(contentConfigStub.called);
-  });
-});
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts
deleted file mode 100644
index 73cf58b..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts
+++ /dev/null
@@ -1,415 +0,0 @@
-/**
- * @license
- * Copyright 2015 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import 'ba-linkify/ba-linkify';
-import {getBaseUrl} from '../../../utils/url-util';
-import {CommentLinkInfo} from '../../../types/common';
-
-/**
- * Pattern describing URLs with supported protocols.
- */
-const URL_PROTOCOL_PATTERN = /^(.*)(https?:\/\/|mailto:)/;
-
-export type LinkTextParserCallback = ((text: string, href: string) => void) &
-  ((text: null, href: null, fragment: DocumentFragment) => void);
-
-export interface CommentLinkItem {
-  position: number;
-  length: number;
-  html: HTMLAnchorElement | DocumentFragment;
-}
-
-export type LinkTextParserConfig = {[name: string]: CommentLinkInfo};
-
-export class GrLinkTextParser {
-  private readonly baseUrl = getBaseUrl();
-
-  /**
-   * Construct a parser for linkifying text. Will linkify plain URLs that appear
-   * in the text as well as custom links if any are specified in the linkConfig
-   * parameter.
-   *
-   * @param linkConfig Comment links as specified by the commentlinks field on a
-   *     project config.
-   * @param callback The callback to be fired when an intermediate parse result
-   *     is emitted. The callback is passed text and href strings if a link is to
-   *     be created, or a document fragment otherwise.
-   * @param removeZeroWidthSpace If true, zero-width spaces will be removed from
-   *     R=<email> and CC=<email> expressions.
-   */
-  constructor(
-    private readonly linkConfig: LinkTextParserConfig,
-    private readonly callback: LinkTextParserCallback,
-    private readonly removeZeroWidthSpace?: boolean
-  ) {
-    Object.preventExtensions(this);
-  }
-
-  /**
-   * Emit a callback to create a link element.
-   *
-   * @param text The text of the link.
-   * @param href The URL to use as the href of the link.
-   */
-  addText(text: string, href: string) {
-    if (!text) {
-      return;
-    }
-    this.callback(text, href);
-  }
-
-  /**
-   * Given the source text and a list of CommentLinkItem objects that were
-   * generated by the commentlinks config, emit parsing callbacks.
-   *
-   * @param text The chuml of source text over which the outputArray items range.
-   * @param outputArray The list of items to add resulting from commentlink
-   *     matches.
-   */
-  processLinks(text: string, outputArray: CommentLinkItem[]) {
-    this.sortArrayReverse(outputArray);
-    const fragment = document.createDocumentFragment();
-    let cursor = text.length;
-
-    // Start inserting linkified URLs from the end of the String. That way, the
-    // string positions of the items don't change as we iterate through.
-    outputArray.forEach(item => {
-      // Add any text between the current linkified item and the item added
-      // before if it exists.
-      if (item.position + item.length !== cursor) {
-        fragment.insertBefore(
-          document.createTextNode(
-            text.slice(item.position + item.length, cursor)
-          ),
-          fragment.firstChild
-        );
-      }
-      fragment.insertBefore(item.html, fragment.firstChild);
-      cursor = item.position;
-    });
-
-    // Add the beginning portion at the end.
-    if (cursor !== 0) {
-      fragment.insertBefore(
-        document.createTextNode(text.slice(0, cursor)),
-        fragment.firstChild
-      );
-    }
-
-    this.callback(null, null, fragment);
-  }
-
-  /**
-   * Sort the given array of CommentLinkItems such that the positions are in
-   * reverse order.
-   */
-  sortArrayReverse(outputArray: CommentLinkItem[]) {
-    outputArray.sort((a, b) => b.position - a.position);
-  }
-
-  addItem(
-    text: string,
-    href: string,
-    html: null,
-    position: number,
-    length: number,
-    outputArray: CommentLinkItem[]
-  ): void;
-
-  addItem(
-    text: null,
-    href: null,
-    html: string,
-    position: number,
-    length: number,
-    outputArray: CommentLinkItem[]
-  ): void;
-
-  /**
-   * Create a CommentLinkItem and append it to the given output array. This
-   * method can be called in either of two ways:
-   * - With `text` and `href` parameters provided, and the `html` parameter
-   *   passed as `null`. In this case, the new CommentLinkItem will be a link
-   *   element with the given text and href value.
-   * - With the `html` paremeter provided, and the `text` and `href` parameters
-   *   passed as `null`. In this case, the string of HTML will be parsed and the
-   *   first resulting node will be used as the resulting content.
-   *
-   * @param text The text to use if creating a link.
-   * @param href The href to use as the URL if creating a link.
-   * @param html The html to parse and use as the result.
-   * @param  position The position inside the source text where the item
-   *     starts.
-   * @param length The number of characters in the source text
-   *     represented by the item.
-   * @param outputArray The array to which the
-   *     new item is to be appended.
-   */
-  addItem(
-    text: string | null,
-    href: string | null,
-    html: string | null,
-    position: number,
-    length: number,
-    outputArray: CommentLinkItem[]
-  ): void {
-    if (href) {
-      const a = document.createElement('a');
-      a.setAttribute('href', href);
-      a.textContent = text;
-      a.target = '_blank';
-      a.rel = 'noopener';
-      outputArray.push({
-        html: a,
-        position,
-        length,
-      });
-    } else if (html) {
-      // addItem has 2 overloads. If href is null, then html
-      // can't be null.
-      // TODO(TS): remove if(html) and keep else block without condition
-      const fragment = document.createDocumentFragment();
-      // Create temporary div to hold the nodes in.
-      const div = document.createElement('div');
-      div.innerHTML = html;
-      while (div.firstChild) {
-        fragment.appendChild(div.firstChild);
-      }
-      outputArray.push({
-        html: fragment,
-        position,
-        length,
-      });
-    }
-  }
-
-  /**
-   * Create a CommentLinkItem for a link and append it to the given output
-   * array.
-   *
-   * @param text The text for the link.
-   * @param href The href to use as the URL of the link.
-   * @param position The position inside the source text where the link
-   *     starts.
-   * @param length The number of characters in the source text
-   *     represented by the link.
-   * @param outputArray The array to which the
-   *     new item is to be appended.
-   */
-  addLink(
-    text: string,
-    href: string,
-    position: number,
-    length: number,
-    outputArray: CommentLinkItem[]
-  ) {
-    // TODO(TS): remove !test condition
-    if (!text || this.hasOverlap(position, length, outputArray)) {
-      return;
-    }
-    if (
-      !!this.baseUrl &&
-      href.startsWith('/') &&
-      !href.startsWith(this.baseUrl)
-    ) {
-      href = this.baseUrl + href;
-    }
-    this.addItem(text, href, null, position, length, outputArray);
-  }
-
-  /**
-   * Create a CommentLinkItem specified by an HTMl string and append it to the
-   * given output array.
-   *
-   * @param html The html to parse and use as the result.
-   * @param position The position inside the source text where the item
-   *     starts.
-   * @param length The number of characters in the source text
-   *     represented by the item.
-   * @param outputArray The array to which the
-   *     new item is to be appended.
-   */
-  addHTML(
-    html: string,
-    position: number,
-    length: number,
-    outputArray: CommentLinkItem[]
-  ) {
-    if (this.hasOverlap(position, length, outputArray)) {
-      return;
-    }
-    if (
-      !!this.baseUrl &&
-      html.match(/<a href="\//g) &&
-      !new RegExp(`<a href="${this.baseUrl}`, 'g').test(html)
-    ) {
-      html = html.replace(/<a href="\//g, `<a href="${this.baseUrl}/`);
-    }
-    this.addItem(null, null, html, position, length, outputArray);
-  }
-
-  /**
-   * Does the given range overlap with anything already in the item list.
-   */
-  hasOverlap(position: number, length: number, outputArray: CommentLinkItem[]) {
-    const endPosition = position + length;
-    for (let i = 0; i < outputArray.length; i++) {
-      const arrayItemStart = outputArray[i].position;
-      const arrayItemEnd = outputArray[i].position + outputArray[i].length;
-      if (
-        (position >= arrayItemStart && position < arrayItemEnd) ||
-        (endPosition > arrayItemStart && endPosition <= arrayItemEnd) ||
-        (position === arrayItemStart && position === arrayItemEnd)
-      ) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  /**
-   * Parse the given source text and emit callbacks for the items that are
-   * parsed.
-   */
-  parse(text?: string | null) {
-    if (text) {
-      window.linkify(text, {
-        callback: (text: string, href?: string) => this.parseChunk(text, href),
-      });
-    }
-  }
-
-  /**
-   * Callback that is pased into the linkify function. ba-linkify will call this
-   * method in either of two ways:
-   * - With both a `text` and `href` parameter provided: this indicates that
-   *   ba-linkify has found a plain URL and wants it linkified.
-   * - With only a `text` parameter provided: this represents the non-link
-   *   content that lies between the links the library has found.
-   *
-   */
-  parseChunk(text: string, href?: string) {
-    // TODO(wyatta) switch linkify sequence, see issue 5526.
-    if (this.removeZeroWidthSpace) {
-      // Remove the zero-width space added in gr-change-view.
-      text = text.replace(/^(CC|R)=\u200B/gm, '$1=');
-    }
-
-    // If the href is provided then ba-linkify has recognized it as a URL. If
-    // the source text does not include a protocol, the protocol will be added
-    // by ba-linkify. Create the link if the href is provided and its protocol
-    // matches the expected pattern.
-    if (href) {
-      const result = URL_PROTOCOL_PATTERN.exec(href);
-      if (result) {
-        const prefixText = result[1];
-        if (prefixText.length > 0) {
-          // Fix for simple cases from
-          // https://bugs.chromium.org/p/gerrit/issues/detail?id=11697
-          // When leading whitespace is missed before link,
-          // linkify add this text before link as a schema name to href.
-          // We suppose, that prefixText just a single word
-          // before link and add this word as is, without processing
-          // any patterns in it.
-          this.parseLinks(prefixText, {});
-          text = text.substring(prefixText.length);
-          href = href.substring(prefixText.length);
-        }
-        this.addText(text, href);
-        return;
-      }
-    }
-    // For the sections of text that lie between the links found by
-    // ba-linkify, we search for the project-config-specified link patterns.
-    this.parseLinks(text, this.linkConfig);
-  }
-
-  /**
-   * Walk over the given source text to find matches for comemntlink patterns
-   * and emit parse result callbacks.
-   *
-   * @param text The raw source text.
-   * @param config A comment links specification object.
-   */
-  parseLinks(text: string, config: LinkTextParserConfig) {
-    // The outputArray is used to store all of the matches found for all
-    // patterns.
-    const outputArray: CommentLinkItem[] = [];
-    for (const [configName, linkInfo] of Object.entries(config)) {
-      // TODO(TS): it seems, the following line can be rewritten as:
-      // if(enabled === false || enabled === 0 || enabled === '')
-      // Should be double-checked before update
-      // eslint-disable-next-line eqeqeq
-      if (linkInfo.enabled != null && linkInfo.enabled == false) {
-        continue;
-      }
-      // PolyGerrit doesn't use hash-based navigation like the GWT UI.
-      // Account for this.
-      const html = linkInfo.html;
-      const link = linkInfo.link;
-      if (html) {
-        linkInfo.html = html.replace(/<a href="#\//g, '<a href="/');
-      } else if (link) {
-        if (link[0] === '#') {
-          linkInfo.link = link.substr(1);
-        }
-      }
-
-      const pattern = new RegExp(linkInfo.match, 'g');
-
-      let match;
-      let textToCheck = text;
-      let susbtrIndex = 0;
-
-      while ((match = pattern.exec(textToCheck))) {
-        textToCheck = textToCheck.substr(match.index + match[0].length);
-        let result = match[0].replace(
-          pattern,
-          // Either html or link has a value. Otherwise an exception is thrown
-          // in the code below.
-          (linkInfo.html || linkInfo.link)!
-        );
-
-        if (linkInfo.html) {
-          let i;
-          // Skip portion of replacement string that is equal to original to
-          // allow overlapping patterns.
-          for (i = 0; i < result.length; i++) {
-            if (result[i] !== match[0][i]) {
-              break;
-            }
-          }
-          result = result.slice(i);
-
-          this.addHTML(
-            result,
-            susbtrIndex + match.index + i,
-            match[0].length - i,
-            outputArray
-          );
-        } else if (linkInfo.link) {
-          this.addLink(
-            match[0],
-            result,
-            susbtrIndex + match.index,
-            match[0].length,
-            outputArray
-          );
-        } else {
-          throw Error(
-            'linkconfig entry ' +
-              configName +
-              ' doesn’t contain a link or html attribute.'
-          );
-        }
-
-        // Update the substring location so we know where we are in relation to
-        // the initial full text string.
-        susbtrIndex = susbtrIndex + match.index + match[0].length;
-      }
-    }
-    this.processLinks(text, outputArray);
-  }
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-markdown/gr-markdown.ts b/polygerrit-ui/app/elements/shared/gr-markdown/gr-markdown.ts
index ae4b004..50f0602 100644
--- a/polygerrit-ui/app/elements/shared/gr-markdown/gr-markdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-markdown/gr-markdown.ts
@@ -5,7 +5,11 @@
  */
 import {css, html, LitElement} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
-import {htmlEscape} from '../../../utils/inner-html-util';
+import {
+  htmlEscape,
+  sanitizeHtml,
+  sanitizeHtmlToFragment,
+} from '../../../utils/inner-html-util';
 import {unescapeHTML} from '../../../utils/syntax-util';
 import '@polymer/marked-element';
 import {resolve} from '../../../models/dependency';
@@ -22,12 +26,15 @@
  * This element renders markdown and also applies some regex replacements to
  * linkify key parts of the text defined by the host's config.
  *
- * TODO: Remove gr-formatted-text once this is rolled out.
+ * TODO: Replace gr-formatted-text with this once markdown flag is rolled out.
  */
 @customElement('gr-markdown')
 export class GrMarkdown extends LitElement {
   @property({type: String})
-  markdown?: string;
+  content = '';
+
+  @property({type: Boolean})
+  markdown = false;
 
   @state()
   private repoCommentLinks: CommentLinks = {};
@@ -86,6 +93,11 @@
       li {
         margin-left: var(--spacing-xl);
       }
+      .plaintext {
+        font: inherit;
+        white-space: var(--linked-text-white-space, pre-wrap);
+        word-wrap: var(--linked-text-word-wrap, break-word);
+      }
     `,
   ];
 
@@ -99,9 +111,25 @@
   }
 
   override render() {
-    // Note: Handling \u200B added in gr-change-view.ts is not needed here
-    // because the commit message is not markdown formatted.
+    if (this.markdown) {
+      return this.renderAsMarkdown();
+    } else {
+      return this.renderAsPlaintext();
+    }
+  }
 
+  private renderAsPlaintext() {
+    const linkedText = this.rewriteText(
+      htmlEscape(this.content).toString(),
+      this.repoCommentLinks
+    );
+
+    return html`
+      <pre class="plaintext">${sanitizeHtmlToFragment(linkedText)}</pre>
+    `;
+  }
+
+  private renderAsMarkdown() {
     // <marked-element> internals will be in charge of calling our custom
     // renderer so we wrap 'this.rewriteText' so that 'this' is preserved via
     // closure.
@@ -130,11 +158,16 @@
     }
 
     // The child with slot is optional but allows us control over the styling.
+    // The `callback` property lets us do a final sanitization of the output
+    // HTML string before it is rendered by `<marked-element>` in case any
+    // rewrites have been abused to attempt an XSS attack.
     return html`
       <marked-element
-        .markdown=${this.escapeAllButBlockQuotes(this.markdown ?? '')}
+        .markdown=${this.escapeAllButBlockQuotes(this.content)}
         .breaks=${true}
         .renderer=${customRenderer}
+        .callback=${(_error: string | null, contents: string) =>
+          sanitizeHtml(contents)}
       >
         <div slot="markdown-html"></div>
       </marked-element>
diff --git a/polygerrit-ui/app/elements/shared/gr-markdown/gr-markdown_test.ts b/polygerrit-ui/app/elements/shared/gr-markdown/gr-markdown_test.ts
index 1128b3a..2327dfe 100644
--- a/polygerrit-ui/app/elements/shared/gr-markdown/gr-markdown_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-markdown/gr-markdown_test.ts
@@ -57,212 +57,89 @@
     ).querySelector('gr-markdown')!;
   });
 
-  test('renders plain text with links and rewrites', async () => {
-    element.markdown = `text
-    \ntext with plain link: google.com
-    \ntext with config link: LinkRewriteMe
-    \ntext with config html: HTMLRewriteMe`;
-    await element.updateComplete;
+  suite('as plaintext', () => {
+    setup(async () => {
+      element.markdown = false;
+      await element.updateComplete;
+    });
 
-    assert.shadowDom.equal(
-      element,
-      /* HTML */ `
-        <marked-element>
-          <div slot="markdown-html">
-            <p>text</p>
-            <p>
-              text with plain link:
-              <a href="http://google.com" rel="noopener" target="_blank">
-                google.com
-              </a>
-            </p>
-            <p>
-              text with config link:
-              <a
-                href="http://google.com/LinkRewriteMe"
-                rel="noopener"
-                target="_blank"
-              >
-                LinkRewriteMe
-              </a>
-            </p>
-            <p>text with config html:</p>
+    test('renders text with links and rewrites', async () => {
+      element.content = `text with plain link: google.com
+        \ntext with config link: LinkRewriteMe
+        \ntext with config html: HTMLRewriteMe`;
+      await element.updateComplete;
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <pre class="plaintext">
+            text with plain link:
+            <a href="http://google.com" rel="noopener" target="_blank">
+              google.com
+            </a>
+            text with config link:
+            <a
+              href="http://google.com/LinkRewriteMe"
+              rel="noopener"
+              target="_blank"
+            >
+              LinkRewriteMe
+            </a>
+            text with config html:
             <div>HTMLRewritten</div>
-            <p></p>
-          </div>
-        </marked-element>
-      `
-    );
+          </pre>
+        `
+      );
+    });
+
+    test('does not render typed html', async () => {
+      element.content = 'plain text <div>foo</div>';
+      await element.updateComplete;
+
+      const escapedDiv = '&lt;div&gt;foo&lt;/div&gt;';
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `<pre class="plaintext">plain text ${escapedDiv}</pre>`
+      );
+    });
+
+    test('does not render markdown', async () => {
+      element.content = '# A Markdown Heading';
+      await element.updateComplete;
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ '<pre class="plaintext"># A Markdown Heading</pre>'
+      );
+    });
   });
 
-  test('renders headings with links and rewrites', async () => {
-    element.markdown = `# h1-heading
-    \n## h2-heading
-    \n### h3-heading
-    \n#### h4-heading
-    \n##### h5-heading
-    \n###### h6-heading
-    \n# heading with plain link: google.com
-    \n# heading with config link: LinkRewriteMe
-    \n# heading with config html: HTMLRewriteMe`;
-    await element.updateComplete;
+  suite('as markdown', () => {
+    setup(async () => {
+      element.markdown = true;
+      await element.updateComplete;
+    });
+    test('renders text with links and rewrites', async () => {
+      element.content = `text
+        \ntext with plain link: google.com
+        \ntext with config link: LinkRewriteMe
+        \ntext with config html: HTMLRewriteMe`;
+      await element.updateComplete;
 
-    assert.shadowDom.equal(
-      element,
-      /* HTML */ `
-        <marked-element>
-          <div slot="markdown-html">
-            <h1 id="h1-heading">h1-heading</h1>
-            <h2 id="h2-heading">h2-heading</h2>
-            <h3 id="h3-heading">h3-heading</h3>
-            <h4 id="h4-heading">h4-heading</h4>
-            <h5 id="h5-heading">h5-heading</h5>
-            <h6 id="h6-heading">h6-heading</h6>
-            <h1 id="heading-with-plain-link-google-com">
-              heading with plain link:
-              <a href="http://google.com" rel="noopener" target="_blank">
-                google.com
-              </a>
-            </h1>
-            <h1 id="heading-with-config-link-linkrewriteme">
-              heading with config link:
-              <a
-                href="http://google.com/LinkRewriteMe"
-                rel="noopener"
-                target="_blank"
-              >
-                LinkRewriteMe
-              </a>
-            </h1>
-            <h1 id="heading-with-config-html-htmlrewriteme">
-              heading with config html:
-              <div>HTMLRewritten</div>
-            </h1>
-          </div>
-        </marked-element>
-      `
-    );
-  });
-
-  test('renders inline-code without linking or rewriting', async () => {
-    element.markdown = `\`inline code\`
-    \n\`inline code with plain link: google.com\`
-    \n\`inline code with config link: LinkRewriteMe\`
-    \n\`inline code with config html: HTMLRewriteMe\``;
-    await element.updateComplete;
-
-    assert.shadowDom.equal(
-      element,
-      /* HTML */ `
-        <marked-element>
-          <div slot="markdown-html">
-            <p>
-              <code> inline code </code>
-            </p>
-            <p>
-              <code> inline code with plain link: google.com </code>
-            </p>
-            <p>
-              <code> inline code with config link: LinkRewriteMe </code>
-            </p>
-            <p>
-              <code> inline code with config html: HTMLRewriteMe </code>
-            </p>
-          </div>
-        </marked-element>
-      `
-    );
-  });
-  test('renders multiline-code without linking or rewriting', async () => {
-    element.markdown = `\`\`\`\nmultiline code\n\`\`\`
-    \n\`\`\`\nmultiline code with plain link: google.com\n\`\`\`
-    \n\`\`\`\nmultiline code with config link: LinkRewriteMe\n\`\`\`
-    \n\`\`\`\nmultiline code with config html: HTMLRewriteMe\n\`\`\``;
-    await element.updateComplete;
-
-    assert.shadowDom.equal(
-      element,
-      /* HTML */ `
-        <marked-element>
-          <div slot="markdown-html">
-            <pre>
-              <code> multiline code </code>
-            </pre>
-            <pre>
-              <code> multiline code with plain link: google.com </code>
-            </pre>
-            <pre>
-              <code> multiline code with config link: LinkRewriteMe </code>
-            </pre>
-            <pre>
-              <code> multiline code with config html: HTMLRewriteMe </code>
-            </pre>
-          </div>
-        </marked-element>
-      `
-    );
-  });
-
-  test('does not render inline images into <img> tags', async () => {
-    element.markdown = '![img](google.com/img.png)';
-    await element.updateComplete;
-
-    assert.shadowDom.equal(
-      element,
-      /* HTML */ `
-        <marked-element>
-          <div slot="markdown-html">
-            <p>![img](google.com/img.png)</p>
-          </div>
-        </marked-element>
-      `
-    );
-  });
-
-  test('renders inline links into <a> tags', async () => {
-    element.markdown = '[myLink](https://www.google.com)';
-    await element.updateComplete;
-
-    assert.shadowDom.equal(
-      element,
-      /* HTML */ `
-        <marked-element>
-          <div slot="markdown-html">
-            <p>
-              <a href="https://www.google.com">myLink</a>
-            </p>
-          </div>
-        </marked-element>
-      `
-    );
-  });
-
-  test('renders block quotes with links and rewrites', async () => {
-    element.markdown = `> block quote
-    \n> block quote with plain link: google.com
-    \n> block quote with config link: LinkRewriteMe
-    \n> block quote with config html: HTMLRewriteMe`;
-    await element.updateComplete;
-
-    assert.shadowDom.equal(
-      element,
-      /* HTML */ `
-        <marked-element>
-          <div slot="markdown-html">
-            <blockquote>
-              <p>block quote</p>
-            </blockquote>
-            <blockquote>
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html">
+              <p>text</p>
               <p>
-                block quote with plain link:
+                text with plain link:
                 <a href="http://google.com" rel="noopener" target="_blank">
                   google.com
                 </a>
               </p>
-            </blockquote>
-            <blockquote>
               <p>
-                block quote with config link:
+                text with config link:
                 <a
                   href="http://google.com/LinkRewriteMe"
                   rel="noopener"
@@ -271,50 +148,236 @@
                   LinkRewriteMe
                 </a>
               </p>
-            </blockquote>
-            <blockquote>
-              <p>block quote with config html:</p>
+              <p>text with config html:</p>
               <div>HTMLRewritten</div>
               <p></p>
-            </blockquote>
-          </div>
-        </marked-element>
-      `
-    );
-  });
+            </div>
+          </marked-element>
+        `
+      );
+    });
 
-  test('never renders typed html', async () => {
-    element.markdown = `plain text <div>foo</div>
-    \n\`inline code <div>foo</div>\`
-    \n\`\`\`\nmultiline code <div>foo</div>\`\`\`
-    \n> block quote <div>foo</div>
-    \n[inline link <div>foo</div>](http://google.com)`;
-    await element.updateComplete;
+    test('renders headings with links and rewrites', async () => {
+      element.content = `# h1-heading
+        \n## h2-heading
+        \n### h3-heading
+        \n#### h4-heading
+        \n##### h5-heading
+        \n###### h6-heading
+        \n# heading with plain link: google.com
+        \n# heading with config link: LinkRewriteMe
+        \n# heading with config html: HTMLRewriteMe`;
+      await element.updateComplete;
 
-    const escapedDiv = '&lt;div&gt;foo&lt;/div&gt;';
-    assert.shadowDom.equal(
-      element,
-      /* HTML */ `
-        <marked-element>
-          <div slot="markdown-html">
-            <p>plain text ${escapedDiv}</p>
-            <p>
-              <code> inline code ${escapedDiv} </code>
-            </p>
-            <pre>
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html">
+              <h1>h1-heading</h1>
+              <h2>h2-heading</h2>
+              <h3>h3-heading</h3>
+              <h4>h4-heading</h4>
+              <h5>h5-heading</h5>
+              <h6>h6-heading</h6>
+              <h1>
+                heading with plain link:
+                <a href="http://google.com" rel="noopener" target="_blank">
+                  google.com
+                </a>
+              </h1>
+              <h1>
+                heading with config link:
+                <a
+                  href="http://google.com/LinkRewriteMe"
+                  rel="noopener"
+                  target="_blank"
+                >
+                  LinkRewriteMe
+                </a>
+              </h1>
+              <h1>
+                heading with config html:
+                <div>HTMLRewritten</div>
+              </h1>
+            </div>
+          </marked-element>
+        `
+      );
+    });
+
+    test('renders inline-code without linking or rewriting', async () => {
+      element.content = `\`inline code\`
+        \n\`inline code with plain link: google.com\`
+        \n\`inline code with config link: LinkRewriteMe\`
+        \n\`inline code with config html: HTMLRewriteMe\``;
+      await element.updateComplete;
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html">
+              <p>
+                <code>inline code</code>
+              </p>
+              <p>
+                <code>inline code with plain link: google.com</code>
+              </p>
+              <p>
+                <code>inline code with config link: LinkRewriteMe</code>
+              </p>
+              <p>
+                <code>inline code with config html: HTMLRewriteMe</code>
+              </p>
+            </div>
+          </marked-element>
+        `
+      );
+    });
+    test('renders multiline-code without linking or rewriting', async () => {
+      element.content = `\`\`\`\nmultiline code\n\`\`\`
+        \n\`\`\`\nmultiline code with plain link: google.com\n\`\`\`
+        \n\`\`\`\nmultiline code with config link: LinkRewriteMe\n\`\`\`
+        \n\`\`\`\nmultiline code with config html: HTMLRewriteMe\n\`\`\``;
+      await element.updateComplete;
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html">
+              <pre>
+              <code>multiline code</code>
+            </pre>
+              <pre>
+              <code>multiline code with plain link: google.com</code>
+            </pre>
+              <pre>
+              <code>multiline code with config link: LinkRewriteMe</code>
+            </pre>
+              <pre>
+              <code>multiline code with config html: HTMLRewriteMe</code>
+            </pre>
+            </div>
+          </marked-element>
+        `
+      );
+    });
+
+    test('does not render inline images into <img> tags', async () => {
+      element.content = '![img](google.com/img.png)';
+      await element.updateComplete;
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html">
+              <p>![img](google.com/img.png)</p>
+            </div>
+          </marked-element>
+        `
+      );
+    });
+
+    test('renders inline links into <a> tags', async () => {
+      element.content = '[myLink](https://www.google.com)';
+      await element.updateComplete;
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html">
+              <p>
+                <a href="https://www.google.com">myLink</a>
+              </p>
+            </div>
+          </marked-element>
+        `
+      );
+    });
+
+    test('renders block quotes with links and rewrites', async () => {
+      element.content = `> block quote
+        \n> block quote with plain link: google.com
+        \n> block quote with config link: LinkRewriteMe
+        \n> block quote with config html: HTMLRewriteMe`;
+      await element.updateComplete;
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html">
+              <blockquote>
+                <p>block quote</p>
+              </blockquote>
+              <blockquote>
+                <p>
+                  block quote with plain link:
+                  <a href="http://google.com" rel="noopener" target="_blank">
+                    google.com
+                  </a>
+                </p>
+              </blockquote>
+              <blockquote>
+                <p>
+                  block quote with config link:
+                  <a
+                    href="http://google.com/LinkRewriteMe"
+                    rel="noopener"
+                    target="_blank"
+                  >
+                    LinkRewriteMe
+                  </a>
+                </p>
+              </blockquote>
+              <blockquote>
+                <p>block quote with config html:</p>
+                <div>HTMLRewritten</div>
+                <p></p>
+              </blockquote>
+            </div>
+          </marked-element>
+        `
+      );
+    });
+
+    test('never renders typed html', async () => {
+      element.content = `plain text <div>foo</div>
+        \n\`inline code <div>foo</div>\`
+        \n\`\`\`\nmultiline code <div>foo</div>\`\`\`
+        \n> block quote <div>foo</div>
+        \n[inline link <div>foo</div>](http://google.com)`;
+      await element.updateComplete;
+
+      const escapedDiv = '&lt;div&gt;foo&lt;/div&gt;';
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html">
+              <p>plain text ${escapedDiv}</p>
+              <p>
+                <code>inline code ${escapedDiv}</code>
+              </p>
+              <pre>
               <code>
                 multiline code ${escapedDiv}
               </code>
             </pre>
-            <blockquote>
-              <p>block quote ${escapedDiv}</p>
-            </blockquote>
-            <p>
-              <a href="http://google.com"> inline link ${escapedDiv} </a>
-            </p>
-          </div>
-        </marked-element>
-      `
-    );
+              <blockquote>
+                <p>block quote ${escapedDiv}</p>
+              </blockquote>
+              <p>
+                <a href="http://google.com">inline link ${escapedDiv}</a>
+              </p>
+            </div>
+          </marked-element>
+        `
+      );
+    });
   });
 });
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
index d958ef4..1637296 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
@@ -637,8 +637,8 @@
       // REST API.
       let content = [
         '      <section class="summary">',
-        '        <gr-linked-text content="' +
-          '[[_computeCurrentRevisionMessage(change)]]"></gr-linked-text>',
+        '        <gr-markdown content="' +
+          '[[_computeCurrentRevisionMessage(change)]]"></gr-markdown>',
         '      </section>',
       ];
       let highlights = [
@@ -664,7 +664,7 @@
         {
           contentIndex: 2,
           startIndex: 0,
-          endIndex: 6,
+          endIndex: 12,
         },
       ]);
       const lines = element.linesFromRows(
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
index cb3eed5..89a0756 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
@@ -22,7 +22,7 @@
         <div data-side="left">
           <div class="comment-thread">
             <div class="gr-formatted-text message">
-              <span id="output" class="gr-linked-text">This is a comment</span>
+              <span id="output" class="gr-markdown">This is a comment</span>
             </div>
           </div>
         </div>
@@ -44,7 +44,7 @@
         <div data-side="right">
           <div class="comment-thread">
             <div class="gr-formatted-text message">
-              <span id="output" class="gr-linked-text"
+              <span id="output" class="gr-markdown"
                 >This is a comment on the right</span
               >
             </div>
@@ -60,7 +60,7 @@
         <div data-side="left">
           <div class="comment-thread">
             <div class="gr-formatted-text message">
-              <span id="output" class="gr-linked-text"
+              <span id="output" class="gr-markdown"
                 >This is <a>a</a> different comment 💩 unicode is fun</span
               >
             </div>
diff --git a/polygerrit-ui/app/utils/inner-html-util.ts b/polygerrit-ui/app/utils/inner-html-util.ts
index 8ac114a..ed9bfac 100644
--- a/polygerrit-ui/app/utils/inner-html-util.ts
+++ b/polygerrit-ui/app/utils/inner-html-util.ts
@@ -8,7 +8,7 @@
 // Internally at Google it has different a implementation.
 
 import {BrandType} from '../types/common';
-export {sanitizeHtml, htmlEscape} from 'safevalues';
+export {sanitizeHtml, htmlEscape, sanitizeHtmlToFragment} from 'safevalues';
 
 export type SafeStyleSheet = BrandType<string, '_safeHtml'>;
 
diff --git a/polygerrit-ui/app/utils/link-util.ts b/polygerrit-ui/app/utils/link-util.ts
index 223f780..fd5965b 100644
--- a/polygerrit-ui/app/utils/link-util.ts
+++ b/polygerrit-ui/app/utils/link-util.ts
@@ -8,10 +8,20 @@
 import {getBaseUrl} from './url-util';
 
 export function linkifyNormalUrls(base: string): string {
+  // Some tools are known to look for reviewers/CCs by finding lines such as
+  // "R=foo@gmail.com, bar@gmail.com". However, "=" is technically a valid email
+  // character, so ba-linkify interprets the entire string "R=foo@gmail.com" as
+  // an email address. To fix this, we insert a zero width space character
+  // \u200B before linking that prevents ba-linkify from associating the prefix
+  // with the email. After linking we remove the zero width space.
+  const baseWithZeroWidthSpace = base.replace(/^(R=|CC=)/g, '$&\u200B');
   const parts: string[] = [];
-  window.linkify(base, {
-    callback: (text, href) =>
-      parts.push(href ? createLinkTemplate(text, href) : text),
+  window.linkify(baseWithZeroWidthSpace, {
+    callback: (text, href) => {
+      const result = href ? createLinkTemplate(text, href) : text;
+      const resultWithoutZeroWidthSpace = result.replace(/\u200B/g, '');
+      parts.push(resultWithoutZeroWidthSpace);
+    },
   });
   return parts.join('');
 }
diff --git a/polygerrit-ui/app/utils/link-util_test.ts b/polygerrit-ui/app/utils/link-util_test.ts
index ecbcb61..c491e35 100644
--- a/polygerrit-ui/app/utils/link-util_test.ts
+++ b/polygerrit-ui/app/utils/link-util_test.ts
@@ -47,13 +47,45 @@
       `${linkedNumber} ${linkedFoo}`
     );
   });
-  test('linkifyNormalUrls', () => {
-    const googleLink = link('google.com', 'http://google.com');
-    const mapsLink = link('maps.google.com', 'http://maps.google.com');
 
-    assert.equal(
-      linkifyNormalUrls('google.com, maps.google.com'),
-      `${googleLink}, ${mapsLink}`
-    );
+  suite('linkifyNormalUrls', () => {
+    test('links urls', () => {
+      const googleLink = link('google.com', 'http://google.com');
+      const mapsLink = link('maps.google.com', 'http://maps.google.com');
+
+      assert.equal(
+        linkifyNormalUrls('google.com, maps.google.com'),
+        `${googleLink}, ${mapsLink}`
+      );
+    });
+
+    test('links emails without including R= prefix', () => {
+      const fooEmail = link('foo@gmail.com', 'mailto:foo@gmail.com');
+      const barEmail = link('bar@gmail.com', 'mailto:bar@gmail.com');
+      assert.equal(
+        linkifyNormalUrls('R=foo@gmail.com, bar@gmail.com'),
+        `R=${fooEmail}, ${barEmail}`
+      );
+    });
+
+    test('links emails without including CC= prefix', () => {
+      const fooEmail = link('foo@gmail.com', 'mailto:foo@gmail.com');
+      const barEmail = link('bar@gmail.com', 'mailto:bar@gmail.com');
+      assert.equal(
+        linkifyNormalUrls('CC=foo@gmail.com, bar@gmail.com'),
+        `CC=${fooEmail}, ${barEmail}`
+      );
+    });
+
+    test('links emails maintains R= and CC= within addresses', () => {
+      const fooBarBazEmail = link(
+        'fooR=barCC=baz@gmail.com',
+        'mailto:fooR=barCC=baz@gmail.com'
+      );
+      assert.equal(
+        linkifyNormalUrls('fooR=barCC=baz@gmail.com'),
+        fooBarBazEmail
+      );
+    });
   });
 });