Merge "Replace gr-formatted-text with 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 ed752e6..76ec316 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-markdown/gr-markdown';
+import '../../shared/gr-formatted-text/gr-formatted-text';
 import '../../shared/gr-overlay/gr-overlay';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../gr-change-actions/gr-change-actions';
@@ -958,7 +958,7 @@
           /* Account for border and padding and rounding errors. */
           max-width: calc(72ch + 2px + 2 * var(--spacing-m) + 0.4px);
         }
-        .commitMessage gr-markdown {
+        .commitMessage gr-formatted-text {
           word-break: break-word;
         }
         #commitMessageEditor {
@@ -1459,9 +1459,10 @@
                 .commitCollapsible=${this.computeCommitCollapsible()}
                 remove-zero-width-space=""
               >
-                <gr-markdown
+                <gr-formatted-text
+                  .markdown=${false}
                   .content=${this.latestCommitMessage ?? ''}
-                ></gr-markdown>
+                ></gr-formatted-text>
               </gr-editable-content>
             </div>
             <h3 class="assistive-tech-only">Comments and Checks Summary</h3>
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 49248ea..e0c09e2 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,7 +433,7 @@
                         id="commitMessageEditor"
                         remove-zero-width-space=""
                       >
-                        <gr-markdown></gr-markdown>
+                        <gr-formatted-text></gr-formatted-text>
                       </gr-editable-content>
                     </div>
                     <h3 class="assistive-tech-only">
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
index e11822f..a4da747 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -10,15 +10,13 @@
 import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../shared/gr-formatted-text/gr-formatted-text';
 import '../gr-message-scores/gr-message-scores';
-import {css, html, LitElement, nothing, PropertyValues} from 'lit';
+import {css, html, LitElement, nothing} from 'lit';
 import {MessageTag, SpecialFilePath} from '../../../constants/constants';
 import {customElement, property, state} from 'lit/decorators.js';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {
   ChangeInfo,
   ServerInfo,
-  ConfigInfo,
-  RepoName,
   ReviewInputTag,
   NumericChangeId,
   ChangeMessageId,
@@ -105,9 +103,6 @@
   @property({type: Boolean})
   hideAutomated = false;
 
-  @property({type: String})
-  projectName?: RepoName;
-
   /**
    * A mapping from label names to objects representing the minimum and
    * maximum possible values for that label.
@@ -115,9 +110,6 @@
   @property({type: Object})
   labelExtremes?: LabelExtreme;
 
-  @state()
-  private projectConfig?: ConfigInfo;
-
   @property({type: Boolean})
   loggedIn = false;
 
@@ -317,12 +309,6 @@
     ];
   }
 
-  override willUpdate(changedProperties: PropertyValues) {
-    if (changedProperties.has('projectName')) {
-      this.projectNameChanged();
-    }
-  }
-
   override render() {
     if (!this.message) return nothing;
     if (this.hideAutomated && this.computeIsAutomated()) return nothing;
@@ -437,10 +423,9 @@
     );
     return html`
       <gr-formatted-text
-        noTrailingMargin
         class="message hideOnCollapsed"
+        .markdown=${true}
         .content=${messageContentExpanded}
-        .config=${this.projectConfig?.commentlinks}
       ></gr-formatted-text>
       ${when(messageContentExpanded, () => this.renderActionContainer())}
       <gr-thread-list
@@ -804,16 +789,6 @@
       });
   }
 
-  private projectNameChanged() {
-    if (!this.projectName) {
-      this.projectConfig = undefined;
-      return;
-    }
-    this.restApiService.getProjectConfig(this.projectName).then(config => {
-      this.projectConfig = config;
-    });
-  }
-
   private computeExpandToggleIcon() {
     return this.message?.expanded ? 'expand_less' : 'expand_more';
   }
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
index da775f6..97b20dc 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -18,7 +18,6 @@
   LabelNameToInfoMap,
   NumericChangeId,
   PatchSetNum,
-  RepoName,
   VotingRangeInfo,
 } from '../../../types/common';
 import {CommentThread, isRobot} from '../../../utils/comment-util';
@@ -314,9 +313,6 @@
   private commentThreads: CommentThread[] = [];
 
   @state()
-  private projectName?: RepoName;
-
-  @state()
   expandAllState = ExpandAllState.EXPAND_ALL;
 
   // Private but used in tests.
@@ -353,13 +349,6 @@
     );
     subscribe(
       this,
-      () => this.changeModel().repo$,
-      x => {
-        this.projectName = x;
-      }
-    );
-    subscribe(
-      this,
       () => this.changeModel().changeNum$,
       x => {
         this.changeNum = x;
@@ -408,7 +397,6 @@
           .changeNum=${this.changeNum}
           .message=${message}
           .commentThreads=${message.commentThreads}
-          .projectName=${this.projectName}
           @message-anchor-tap=${this.handleAnchorClick}
           .labelExtremes=${labelExtremes}
           data-message-id=${ifDefined(getMessageId(message) as String)}
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
index 37df7ec..7fe070c 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
@@ -189,7 +189,7 @@
       </div>
       <div class="sectionContent">
         <gr-formatted-text
-          noTrailingMargin
+          .markdown=${true}
           .content=${description}
         ></gr-formatted-text>
       </div>
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts
index 9f44884..2117ec5 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts
@@ -223,7 +223,7 @@
               <gr-icon icon="description"></gr-icon>
             </div>
             <div class="sectionContent">
-              <gr-formatted-text notrailingmargin=""></gr-formatted-text>
+              <gr-formatted-text></gr-formatted-text>
             </div>
           </div>
           <div class="button">
diff --git a/polygerrit-ui/app/elements/change/gr-trigger-vote-hovercard/gr-trigger-vote-hovercard.ts b/polygerrit-ui/app/elements/change/gr-trigger-vote-hovercard/gr-trigger-vote-hovercard.ts
index bd52a33..db49e8d 100644
--- a/polygerrit-ui/app/elements/change/gr-trigger-vote-hovercard/gr-trigger-vote-hovercard.ts
+++ b/polygerrit-ui/app/elements/change/gr-trigger-vote-hovercard/gr-trigger-vote-hovercard.ts
@@ -89,7 +89,7 @@
       </div>
       <div class="sectionContent">
         <gr-formatted-text
-          noTrailingMargin
+          .markdown=${true}
           .content=${description}
         ></gr-formatted-text>
       </div>
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index b2636c8..26fa0cb 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -54,11 +54,7 @@
 import {charsOnly} from '../../utils/string-util';
 import {isAttemptSelected, matches} from './gr-checks-util';
 import {ChecksTabState, ValueChangedEvent} from '../../types/events';
-import {
-  ConfigInfo,
-  LabelNameToInfoMap,
-  PatchSetNumber,
-} from '../../types/common';
+import {LabelNameToInfoMap, PatchSetNumber} from '../../types/common';
 import {spinnerStyles} from '../../styles/gr-spinner-styles';
 import {
   getLabelStatus,
@@ -70,7 +66,6 @@
 import {fontStyles} from '../../styles/gr-font-styles';
 import {fire} from '../../utils/event-util';
 import {resolve} from '../../models/dependency';
-import {configModelToken} from '../../models/config/config-model';
 import {checksModelToken} from '../../models/checks/checks-model';
 import {Interaction} from '../../constants/reporting';
 import {Deduping} from '../../api/reporting';
@@ -627,13 +622,8 @@
   @property({type: Boolean})
   hideCodePointers = false;
 
-  @state()
-  repoConfig?: ConfigInfo;
-
   private getChangeModel = resolve(this, changeModelToken);
 
-  private getConfigModel = resolve(this, configModelToken);
-
   static override get styles() {
     return [
       sharedStyles,
@@ -655,15 +645,6 @@
     ];
   }
 
-  constructor() {
-    super();
-    subscribe(
-      this,
-      () => this.getConfigModel().repoConfig$,
-      x => (this.repoConfig = x)
-    );
-  }
-
   override render() {
     if (!this.result) return '';
     return html`
@@ -679,10 +660,9 @@
           .value=${this.result}
         ></gr-endpoint-param>
         <gr-formatted-text
-          noTrailingMargin
           class="message"
-          .content=${this.result.message}
-          .config=${this.repoConfig?.commentlinks}
+          .markdown=${true}
+          .content=${this.result.message ?? ''}
         ></gr-formatted-text>
       </gr-endpoint-decorator>
     `;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index cc76150..6f40254 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -24,7 +24,6 @@
 import {GrOverlay} from '../gr-overlay/gr-overlay';
 import {
   AccountDetailInfo,
-  CommentLinks,
   NumericChangeId,
   RepoName,
   RobotCommentInfo,
@@ -60,7 +59,6 @@
 import {CommentSide, SpecialFilePath} from '../../../constants/constants';
 import {Subject} from 'rxjs';
 import {debounceTime} from 'rxjs/operators';
-import {configModelToken} from '../../../models/config/config-model';
 import {changeModelToken} from '../../../models/change/change-model';
 import {Interaction} from '../../../constants/reporting';
 import {KnownExperimentId} from '../../../services/flags/flags';
@@ -192,9 +190,6 @@
   editing = false;
 
   @state()
-  commentLinks: CommentLinks = {};
-
-  @state()
   repoName?: RepoName;
 
   /* The 'dirty' state of the comment.message, which will be saved on demand. */
@@ -239,8 +234,6 @@
 
   private readonly userModel = getAppContext().userModel;
 
-  private readonly configModel = resolve(this, configModelToken);
-
   private readonly shortcuts = new ShortcutController(this);
 
   /**
@@ -289,11 +282,6 @@
     }
     subscribe(
       this,
-      () => this.configModel().repoCommentLinks$,
-      x => (this.commentLinks = x)
-    );
-    subscribe(
-      this,
       () => this.userModel.account$,
       x => (this.account = x)
     );
@@ -724,8 +712,8 @@
           gr-diff-selection.-->
       <gr-formatted-text
         class="message"
-        .content=${this.comment?.message}
-        .config=${this.commentLinks}
+        .markdown=${true}
+        .content=${this.comment?.message ?? ''}
       ></gr-formatted-text>
     `;
   }
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 4fee574..bd01af9 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
@@ -1,59 +1,205 @@
 /**
  * @license
- * Copyright 2016 Google LLC
+ * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import '../gr-markdown/gr-markdown';
-import {CommentLinks} from '../../../types/common';
-import {LitElement, css, html, TemplateResult} from 'lit';
-import {customElement, property} from 'lit/decorators.js';
+import {css, html, LitElement} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {
+  htmlEscape,
+  sanitizeHtml,
+  sanitizeHtmlToFragment,
+} from '../../../utils/inner-html-util';
+import {unescapeHTML} from '../../../utils/syntax-util';
+import '@polymer/marked-element';
+import {resolve} from '../../../models/dependency';
+import {subscribe} from '../../lit/subscription-controller';
+import {configModelToken} from '../../../models/config/config-model';
+import {CommentLinks} from '../../../api/rest-api';
+import {
+  applyHtmlRewritesFromConfig,
+  applyLinkRewritesFromConfig,
+  linkifyNormalUrls,
+} from '../../../utils/link-util';
 
-const CODE_MARKER_PATTERN = /^(`{1,3})([^`]+?)\1$/;
-const INLINE_PATTERN = /(\[.+?\]\(.+?\)|`[^`]+?`)/;
-const EXTRACT_LINK_PATTERN = /\[(.+?)\]\((.+?)\)/;
+/**
+ * This element optionally renders markdown and also applies some regex
+ * replacements to linkify key parts of the text defined by the host's config.
+ */
+@customElement('gr-formatted-text')
+export class GrFormattedText extends LitElement {
+  @property({type: String})
+  content = '';
 
-export type Block = ListBlock | QuoteBlock | Paragraph | CodeBlock | PreBlock;
-export interface ListBlock {
-  type: 'list';
-  items: ListItem[];
-}
-export interface ListItem {
-  spans: InlineItem[];
-}
+  @property({type: Boolean})
+  markdown = false;
 
-export interface QuoteBlock {
-  type: 'quote';
-  blocks: Block[];
-}
-export interface Paragraph {
-  type: 'paragraph';
-  spans: InlineItem[];
-}
-export interface CodeBlock {
-  type: 'code';
-  text: string;
-}
-export interface PreBlock {
-  type: 'pre';
-  text: string;
-}
+  @state()
+  private repoCommentLinks: CommentLinks = {};
 
-export type InlineItem = TextSpan | LinkSpan | CodeSpan;
+  private readonly getConfigModel = resolve(this, configModelToken);
 
-export interface TextSpan {
-  type: 'text';
-  text: string;
-}
+  /**
+   * Note: Do not use sharedStyles or other styles here that should not affect
+   * the generated HTML of the markdown.
+   */
+  static override styles = [
+    css`
+      a {
+        color: var(--link-color);
+      }
+      p,
+      ul,
+      code,
+      blockquote {
+        margin: 0 0 var(--spacing-m) 0;
+        max-width: var(--gr-formatted-text-prose-max-width, none);
+      }
+      p:last-child,
+      ul:last-child,
+      blockquote:last-child,
+      pre:last-child {
+        margin: 0;
+      }
+      blockquote {
+        border-left: var(--spacing-xxs) solid var(--comment-quote-marker-color);
+        padding: 0 var(--spacing-m);
+      }
+      code {
+        background-color: var(--background-color-secondary);
+        border: var(--spacing-xxs) solid var(--border-color);
+        display: block;
+        font-family: var(--monospace-font-family);
+        font-size: var(--font-size-code);
+        line-height: var(--line-height-mono);
+        margin: var(--spacing-m) 0;
+        padding: var(--spacing-xxs) var(--spacing-s);
+        overflow-x: auto;
+        /* Pre will preserve whitespace and line breaks but not wrap */
+        white-space: pre;
+      }
+      /* Non-multiline code elements need display:inline to shrink and not take
+         a whole row */
+      :not(pre) > code {
+        display: inline;
+      }
+      p {
+        /* prose will automatically wrap but inline <code> blocks won't and we
+           should overflow in that case rather than wrapping or leaking out */
+        overflow-x: auto;
+      }
+      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);
+      }
+    `,
+  ];
 
-export interface LinkSpan {
-  type: 'link';
-  text: string;
-  url: string;
-}
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getConfigModel().repoCommentLinks$,
+      repoCommentLinks => (this.repoCommentLinks = repoCommentLinks)
+    );
+  }
 
-export interface CodeSpan {
-  type: 'code';
-  text: string;
+  override render() {
+    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.
+    const boundRewriteText = (text: string) =>
+      this.rewriteText(text, this.repoCommentLinks);
+
+    // We are overriding some marked-element renderers for a few reasons:
+    // 1. Disable inline images as a design/policy choice.
+    // 2. Inline code blocks ("codespan") do not unescape HTML characters when
+    //    rendering without <pre> and so we must do this manually.
+    //    <marked-element> is already escaping these internally. See test
+    //    covering this.
+    // 3. Multiline code blocks ("code") is similarly handling escaped
+    //    characters using <pre>. The convention is to only use <pre> for multi-
+    //    line code blocks so it is not used for inline code blocks. See test
+    //    for this.
+    // 4. Rewrite plain text ("text") to apply linking and other config-based
+    //    rewrites. Text within code blocks is not passed here.
+    function customRenderer(renderer: {[type: string]: Function}) {
+      renderer['image'] = (href: string, _title: string, text: string) =>
+        `![${text}](${href})`;
+      renderer['codespan'] = (text: string) =>
+        `<code>${unescapeHTML(text)}</code>`;
+      renderer['code'] = (text: string) => `<pre><code>${text}</code></pre>`;
+      renderer['text'] = boundRewriteText;
+    }
+
+    // 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.content)}
+        .breaks=${true}
+        .renderer=${customRenderer}
+        .callback=${(_error: string | null, contents: string) =>
+          sanitizeHtml(contents)}
+      >
+        <div slot="markdown-html"></div>
+      </marked-element>
+    `;
+  }
+
+  private escapeAllButBlockQuotes(text: string) {
+    // Escaping the message should be done first to make sure user's literal
+    // input does not get rendered without affecting html added in later steps.
+    text = htmlEscape(text).toString();
+    // Unescape block quotes '>'. This is slightly dangerous as '>' can be used
+    // in HTML fragments, but it is insufficient on it's own.
+    text = text.replace(/(^|\n)&gt;/g, '$1>');
+
+    return text;
+  }
+
+  private rewriteText(text: string, repoCommentLinks: CommentLinks) {
+    // Turn universally identifiable URLs into links. Ex: www.google.com. The
+    // markdown library inside marked-element does this too, but is more
+    // conservative and misses some URLs like "google.com" without "www" prefix.
+    text = linkifyNormalUrls(text);
+
+    // Apply the host's config-specific regex replacements to create links. Ex:
+    // link "Bug 12345" to "google.com/bug/12345"
+    text = applyLinkRewritesFromConfig(text, repoCommentLinks);
+
+    // Apply the host's config-specific regex replacements to write arbitrary
+    // html. Most examples seen in the wild are also used for linking but with
+    // finer control over the rendered text. Ex: "Bug 12345" => "#12345"
+    text = applyHtmlRewritesFromConfig(text, repoCommentLinks);
+
+    return text;
+  }
 }
 
 declare global {
@@ -61,344 +207,3 @@
     'gr-formatted-text': GrFormattedText;
   }
 }
-@customElement('gr-formatted-text')
-export class GrFormattedText extends LitElement {
-  @property({type: String})
-  content?: string;
-
-  @property({type: Object})
-  config?: CommentLinks;
-
-  @property({type: Boolean, reflect: true})
-  noTrailingMargin = false;
-
-  static override get styles() {
-    return [
-      css`
-        :host {
-          display: block;
-          font-family: var(--font-family);
-        }
-        a {
-          color: var(--link-color);
-        }
-        p,
-        ul,
-        code,
-        blockquote,
-        gr-markdown.pre {
-          margin: 0 0 var(--spacing-m) 0;
-        }
-        p,
-        ul,
-        code,
-        blockquote {
-          max-width: var(--gr-formatted-text-prose-max-width, none);
-        }
-        :host([noTrailingMargin]) p:last-child,
-        :host([noTrailingMargin]) ul:last-child,
-        :host([noTrailingMargin]) blockquote:last-child,
-        :host([noTrailingMargin]) gr-markdown.pre:last-child {
-          margin: 0;
-        }
-        blockquote {
-          border-left: 1px solid #aaa;
-          padding: 0 var(--spacing-m);
-        }
-        code {
-          display: block;
-          /* pre will preserve whitespace and linebreaks but not wrap */
-          white-space: pre;
-          background-color: var(--background-color-secondary);
-          border: 1px solid var(--border-color);
-          border-left-width: var(--spacing-s);
-          margin: var(--spacing-m) 0;
-          padding: var(--spacing-s) var(--spacing-m);
-          overflow-x: auto;
-        }
-        li {
-          list-style-type: disc;
-          margin-left: var(--spacing-xl);
-        }
-        .inline-code,
-        code {
-          font-family: var(--monospace-font-family);
-          font-size: var(--font-size-code);
-          line-height: var(--line-height-mono);
-          background-color: var(--background-color-secondary);
-          border: 1px solid var(--border-color);
-          padding: 1px var(--spacing-s);
-        }
-      `,
-    ];
-  }
-
-  override render() {
-    if (!this.content) return;
-
-    return html`<gr-markdown
-      .markdown=${true}
-      .content=${this.content}
-    ></gr-markdown>`;
-  }
-
-  /**
-   * Given a source string, parse into an array of block objects. Each block
-   * has a `type` property which takes any of the following values.
-   * * 'paragraph' (Paragraph of regular text)
-   * * 'quote' (Block quote.)
-   * * 'pre' (Pre-formatted text.)
-   * * 'list' (Unordered list.)
-   * * 'code' (code blocks.)
-   *
-   * For blocks of type 'paragraph' there is a list of spans that is the content
-   * for that paragraph.
-   *
-   * For blocks of type 'pre' and 'code' there is a `text`
-   * property that maps to a string of the block's content.
-   *
-   * For blocks of type 'list', there is an `items` property that maps to a
-   * list of strings representing the list items.
-   *
-   * For blocks of type 'quote', there is a `blocks` property that maps to a
-   * list of blocks contained in the quote.
-   *
-   * NOTE: Strings appearing in all block objects are NOT escaped.
-   */
-  _computeBlocks(content: string): Block[] {
-    const result: Block[] = [];
-    const lines = content.replace(/[\s\n\r\t]+$/g, '').split('\n');
-
-    for (let i = 0; i < lines.length; i++) {
-      if (!lines[i].length) {
-        continue;
-      }
-
-      if (this.isCodeMarkLine(lines[i])) {
-        const startOfCode = i + 1;
-        const endOfCode = this.getEndOfSection(
-          lines,
-          startOfCode,
-          line => !this.isCodeMarkLine(line)
-        );
-        result.push({
-          type: 'code',
-          // Does not include either of the ``` lines
-          text: lines.slice(startOfCode, endOfCode).join('\n'),
-        });
-        i = endOfCode; // advances past the closing```
-        continue;
-      }
-      if (this.isSingleLineCode(lines[i])) {
-        // no guard check as _isSingleLineCode tested on the pattern
-        const codeContent = lines[i].match(CODE_MARKER_PATTERN)![2];
-        result.push({type: 'code', text: codeContent});
-      } else if (this.isList(lines[i])) {
-        const endOfList = this.getEndOfSection(lines, i + 1, line =>
-          this.isList(line)
-        );
-        result.push(this.makeList(lines.slice(i, endOfList)));
-        i = endOfList - 1;
-      } else if (this.isQuote(lines[i])) {
-        const endOfQuote = this.getEndOfSection(lines, i + 1, line =>
-          this.isQuote(line)
-        );
-        const blockLines = lines
-          .slice(i, endOfQuote)
-          .map(l => l.replace(/^[ ]?>[ ]?/, ''));
-        result.push({
-          type: 'quote',
-          blocks: this._computeBlocks(blockLines.join('\n')),
-        });
-        i = endOfQuote - 1;
-      } else if (this.isPreFormat(lines[i])) {
-        const endOfPre = this.findEndOfPreBlock(lines, i);
-        result.push({
-          type: 'pre',
-          text: lines.slice(i, endOfPre).join('\n'),
-        });
-        i = endOfPre - 1;
-      } else {
-        const endOfRegularLines = this.getEndOfSection(lines, i + 1, line =>
-          this.isRegularLine(line)
-        );
-        result.push({
-          type: 'paragraph',
-          spans: this.computeInlineItems(
-            lines.slice(i, endOfRegularLines).join('\n')
-          ),
-        });
-        i = endOfRegularLines - 1;
-      }
-    }
-
-    return result;
-  }
-
-  private computeInlineItems(content: string): InlineItem[] {
-    const result: InlineItem[] = [];
-    const textSpans = content.split(INLINE_PATTERN);
-    for (let i = 0; i < textSpans.length; ++i) {
-      // Because INLINE_PATTERN has a single capturing group, string.split will
-      // return strings before and after each match as well as the matched
-      // group. These are always interleaved starting with a non-matched string
-      // which may be empty.
-      if (textSpans[i].length === 0) {
-        // No point in processing empty strings.
-        continue;
-      } else if (i % 2 === 0) {
-        // A non-matched string.
-        result.push({type: 'text', text: textSpans[i]});
-      } else if (textSpans[i].startsWith('`')) {
-        result.push({type: 'code', text: textSpans[i].slice(1, -1)});
-      } else {
-        const m = textSpans[i].match(EXTRACT_LINK_PATTERN);
-        if (!m) {
-          result.push({type: 'text', text: textSpans[i]});
-        } else {
-          // eslint-disable-next-line @typescript-eslint/no-unused-vars
-          const [_, text, url] = m;
-          result.push({type: 'link', text, url});
-        }
-      }
-    }
-    return result;
-  }
-
-  private getEndOfSection(
-    lines: string[],
-    startIndex: number,
-    sectionPredicate: (line: string) => boolean
-  ) {
-    const index = lines
-      .slice(startIndex)
-      .findIndex(line => !sectionPredicate(line));
-    return index === -1 ? lines.length : index + startIndex;
-  }
-
-  private findEndOfPreBlock(lines: string[], startIndex: number) {
-    let lastPreFormat = startIndex;
-    for (let i = startIndex + 1; i < lines.length; ++i) {
-      const line = lines[i];
-      if (this.isPreFormat(line)) {
-        lastPreFormat = i;
-      } else if (!this.isWhitespaceLine(line) && line.length !== 0) {
-        break;
-      }
-    }
-    return lastPreFormat + 1;
-  }
-
-  /**
-   * Take a block of comment text that contains a list, generate appropriate
-   * block objects and append them to the output list.
-   *
-   * * Item one.
-   * * Item two.
-   * * item three.
-   *
-   * TODO(taoalpha): maybe we should also support nested list
-   *
-   * @param lines The block containing the list.
-   */
-  private makeList(lines: string[]): Block {
-    return {
-      type: 'list',
-      items: lines.map(line => {
-        return {
-          spans: this.computeInlineItems(line.substring(1).trim()),
-        };
-      }),
-    };
-  }
-
-  private isRegularLine(line: string): boolean {
-    return (
-      !this.isQuote(line) &&
-      !this.isCodeMarkLine(line) &&
-      !this.isSingleLineCode(line) &&
-      !this.isList(line) &&
-      !this.isPreFormat(line)
-    );
-  }
-
-  private isQuote(line: string): boolean {
-    return line.startsWith('> ') || line.startsWith(' > ');
-  }
-
-  private isCodeMarkLine(line: string): boolean {
-    return /^\s{0,3}```/.test(line);
-  }
-
-  private isSingleLineCode(line: string): boolean {
-    return CODE_MARKER_PATTERN.test(line);
-  }
-
-  private isPreFormat(line: string): boolean {
-    return /^(\s{4}|\t)/.test(line) && !this.isWhitespaceLine(line);
-  }
-
-  private isList(line: string): boolean {
-    return /^[-*] /.test(line);
-  }
-
-  private isWhitespaceLine(line: string): boolean {
-    return /^\s+$/.test(line);
-  }
-
-  private renderInlineText(content: string): TemplateResult {
-    return html`<gr-markdown .content=${content}></gr-markdown>`;
-  }
-
-  private renderLink(text: string, url: string): TemplateResult {
-    return html`<a target="_blank" href=${url}>${text}</a>`;
-  }
-
-  private renderInlineCode(text: string): TemplateResult {
-    return html`<span class="inline-code">${text}</span>`;
-  }
-
-  private renderInlineItem(span: InlineItem): TemplateResult {
-    switch (span.type) {
-      case 'text':
-        return this.renderInlineText(span.text);
-      case 'link':
-        return this.renderLink(span.text, span.url);
-      case 'code':
-        return this.renderInlineCode(span.text);
-      default:
-        return html``;
-    }
-  }
-
-  private renderListItem(item: ListItem): TemplateResult {
-    return html` <li>
-      ${item.spans.map(item => this.renderInlineItem(item))}
-    </li>`;
-  }
-
-  private renderBlock(block: Block): TemplateResult {
-    switch (block.type) {
-      case 'paragraph':
-        return html` <p>
-          ${block.spans.map(item => this.renderInlineItem(item))}
-        </p>`;
-      case 'quote':
-        return html`
-          <blockquote>
-            ${block.blocks.map(subBlock => this.renderBlock(subBlock))}
-          </blockquote>
-        `;
-      case 'code':
-        return html`<code>${block.text}</code>`;
-      case 'pre':
-        return html`<pre><code>${block.text}</code></pre>`;
-      case 'list':
-        return html`
-          <ul>
-            ${block.items.map(item => this.renderListItem(item))}
-          </ul>
-        `;
-    }
-  }
-}
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 a3eff8e..dcd13bd 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
@@ -1,519 +1,383 @@
 /**
  * @license
- * Copyright 2016 Google LLC
+ * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {fixture, html, assert} from '@open-wc/testing';
 import '../../../test/common-test-setup';
-import './gr-formatted-text';
+import {assert, fixture, html} from '@open-wc/testing';
+import {changeModelToken} from '../../../models/change/change-model';
 import {
-  GrFormattedText,
-  Block,
-  ListBlock,
-  Paragraph,
-  QuoteBlock,
-  PreBlock,
-  CodeBlock,
-  InlineItem,
-  ListItem,
-  TextSpan,
-  LinkSpan,
-} from './gr-formatted-text';
+  ConfigModel,
+  configModelToken,
+} from '../../../models/config/config-model';
+import {wrapInProvider} from '../../../models/di-provider-element';
+import {getAppContext} from '../../../services/app-context';
+import './gr-formatted-text';
+import {GrFormattedText} from './gr-formatted-text';
+import {createConfig} from '../../../test/test-data-generators';
+import {waitUntilObserved} from '../../../test/test-utils';
+import {CommentLinks} from '../../../api/rest-api';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-formatted-text tests', () => {
   let element: GrFormattedText;
+  let configModel: ConfigModel;
 
-  function assertSpan(actual: InlineItem, expected: InlineItem) {
-    assert.equal(actual.type, expected.type);
-    assert.equal(actual.text, expected.text);
-    switch (actual.type) {
-      case 'link':
-        assert.equal(actual.url, (expected as LinkSpan).url);
-        break;
-    }
-  }
-
-  function assertTextBlock(block: Block, spans: InlineItem[]) {
-    assert.equal(block.type, 'paragraph');
-    const paragraph = block as Paragraph;
-    assert.equal(paragraph.spans.length, spans.length);
-    for (let i = 0; i < paragraph.spans.length; ++i) {
-      assertSpan(paragraph.spans[i], spans[i]);
-    }
-  }
-
-  function assertPreBlock(block: Block, text: string) {
-    assert.equal(block.type, 'pre');
-    const preBlock = block as PreBlock;
-    assert.equal(preBlock.text, text);
-  }
-
-  function assertCodeBlock(block: Block, text: string) {
-    assert.equal(block.type, 'code');
-    const preBlock = block as CodeBlock;
-    assert.equal(preBlock.text, text);
-  }
-
-  function assertSimpleTextBlock(block: Block, text: string) {
-    assertTextBlock(block, [{type: 'text', text}]);
-  }
-
-  function assertListBlock(block: Block, items: ListItem[]) {
-    assert.equal(block.type, 'list');
-    const listBlock = block as ListBlock;
-    assert.deepEqual(listBlock.items, items);
-  }
-
-  function assertQuoteBlock(block: Block): QuoteBlock {
-    assert.equal(block.type, 'quote');
-    return block as QuoteBlock;
+  async function setCommentLinks(commentlinks: CommentLinks) {
+    configModel.updateRepoConfig({...createConfig(), commentlinks});
+    await waitUntilObserved(
+      configModel.repoCommentLinks$,
+      links => links === commentlinks
+    );
   }
 
   setup(async () => {
-    element = await fixture(html`<gr-formatted-text></gr-formatted-text>`);
-  });
-
-  test('parse empty', () => {
-    assert.lengthOf(element._computeBlocks(''), 0);
-  });
-
-  test('render', async () => {
-    element.content = 'text `code`';
-    await element.updateComplete;
-
-    assert.shadowDom.equal(element, /* HTML */ ` <gr-markdown></gr-markdown> `);
-  });
-
-  for (const text of [
-    'Para1',
-    'Para 1\nStill para 1',
-    'Para 1\n\nPara 2\n\nPara 3',
-  ]) {
-    test('parse simple', () => {
-      const comment = {type: 'text', text} as TextSpan;
-      const result = element._computeBlocks(text);
-      assert.lengthOf(result, 1);
-      assertTextBlock(result[0], [comment]);
-    });
-  }
-
-  test('parse link', () => {
-    const comment = '[text](url)';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertTextBlock(result[0], [{type: 'link', text: 'text', url: 'url'}]);
-  });
-
-  test('parse inline code', () => {
-    const comment = 'text `code`';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertTextBlock(result[0], [
-      {type: 'text', text: 'text '},
-      {type: 'code', text: 'code'},
-    ]);
-  });
-
-  test('parse quote', () => {
-    const comment = '> Quote text';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    const quoteBlock = assertQuoteBlock(result[0]);
-    assert.lengthOf(quoteBlock.blocks, 1);
-    assertSimpleTextBlock(quoteBlock.blocks[0], 'Quote text');
-  });
-
-  test('parse quote lead space', () => {
-    const comment = ' > Quote text';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    const quoteBlock = assertQuoteBlock(result[0]);
-    assert.lengthOf(quoteBlock.blocks, 1);
-    assertSimpleTextBlock(quoteBlock.blocks[0], 'Quote text');
-  });
-
-  test('parse multiline quote', () => {
-    const comment = '> Quote line 1\n> Quote line 2\n > Quote line 3\n';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    const quoteBlock = assertQuoteBlock(result[0]);
-    assert.lengthOf(quoteBlock.blocks, 1);
-    assertSimpleTextBlock(
-      quoteBlock.blocks[0],
-      'Quote line 1\nQuote line 2\nQuote line 3'
+    configModel = new ConfigModel(
+      testResolver(changeModelToken),
+      getAppContext().restApiService
     );
-  });
-
-  test('parse pre', () => {
-    const comment = '    Four space indent.';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertPreBlock(result[0], comment);
-  });
-
-  test('one space is not a pre', () => {
-    const comment = ' One space indent.\n Another line.';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertSimpleTextBlock(result[0], comment);
-  });
-
-  test('parse multi-line space pre', () => {
-    const comment = '    One space indent.\n    Another line.';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertPreBlock(result[0], comment);
-  });
-
-  test('parse tab pre', () => {
-    const comment = '\tOne tab indent.\n\tAnother line.\n    Yet another!';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertPreBlock(result[0], comment);
-  });
-
-  test('parse star list', () => {
-    const comment = '* Item 1\n* Item 2\n* Item 3';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertListBlock(result[0], [
-      {spans: [{type: 'text', text: 'Item 1'}]},
-      {spans: [{type: 'text', text: 'Item 2'}]},
-      {spans: [{type: 'text', text: 'Item 3'}]},
-    ]);
-  });
-
-  test('parse dash list', () => {
-    const comment = '- Item 1\n- Item 2\n- Item 3';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertListBlock(result[0], [
-      {spans: [{type: 'text', text: 'Item 1'}]},
-      {spans: [{type: 'text', text: 'Item 2'}]},
-      {spans: [{type: 'text', text: 'Item 3'}]},
-    ]);
-  });
-
-  test('parse mixed list', () => {
-    const comment = '- Item 1\n* Item 2\n- Item 3\n* Item 4';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertListBlock(result[0], [
-      {spans: [{type: 'text', text: 'Item 1'}]},
-      {spans: [{type: 'text', text: 'Item 2'}]},
-      {spans: [{type: 'text', text: 'Item 3'}]},
-      {spans: [{type: 'text', text: 'Item 4'}]},
-    ]);
-  });
-
-  test('parse mixed block types', () => {
-    const comment =
-      'Paragraph\nacross\na\nfew\nlines.' +
-      '\n\n' +
-      '> Quote\n> across\n> not many lines.' +
-      '\n\n' +
-      'Another paragraph' +
-      '\n\n' +
-      '* Series\n* of\n* list\n* items' +
-      '\n\n' +
-      'Yet another paragraph' +
-      '\n\n' +
-      '\tPreformatted text.' +
-      '\n\n' +
-      'Parting words.';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 7);
-    assertSimpleTextBlock(result[0], 'Paragraph\nacross\na\nfew\nlines.\n');
-
-    const quoteBlock = assertQuoteBlock(result[1]);
-    assert.lengthOf(quoteBlock.blocks, 1);
-    assertSimpleTextBlock(
-      quoteBlock.blocks[0],
-      'Quote\nacross\nnot many lines.'
-    );
-
-    assertSimpleTextBlock(result[2], 'Another paragraph\n');
-    assertListBlock(result[3], [
-      {spans: [{type: 'text', text: 'Series'}]},
-      {spans: [{type: 'text', text: 'of'}]},
-      {spans: [{type: 'text', text: 'list'}]},
-      {spans: [{type: 'text', text: 'items'}]},
-    ]);
-    assertSimpleTextBlock(result[4], 'Yet another paragraph\n');
-    assertPreBlock(result[5], '\tPreformatted text.');
-    assertSimpleTextBlock(result[6], 'Parting words.');
-  });
-
-  test('bullet list 1', () => {
-    const comment = 'A\n\n* line 1';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertSimpleTextBlock(result[0], 'A\n');
-    assertListBlock(result[1], [{spans: [{type: 'text', text: 'line 1'}]}]);
-  });
-
-  test('bullet list 2', () => {
-    const comment = 'A\n\n* line 1\n* 2nd line';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertSimpleTextBlock(result[0], 'A\n');
-    assertListBlock(result[1], [
-      {spans: [{type: 'text', text: 'line 1'}]},
-      {spans: [{type: 'text', text: '2nd line'}]},
-    ]);
-  });
-
-  test('bullet list 3', () => {
-    const comment = 'A\n* line 1\n* 2nd line\n\nB';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 3);
-    assertSimpleTextBlock(result[0], 'A');
-    assertListBlock(result[1], [
-      {spans: [{type: 'text', text: 'line 1'}]},
-      {spans: [{type: 'text', text: '2nd line'}]},
-    ]);
-    assertSimpleTextBlock(result[2], 'B');
-  });
-
-  test('bullet list 4', () => {
-    const comment = '* line 1\n* 2nd line\n\nB';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertListBlock(result[0], [
-      {spans: [{type: 'text', text: 'line 1'}]},
-      {spans: [{type: 'text', text: '2nd line'}]},
-    ]);
-    assertSimpleTextBlock(result[1], 'B');
-  });
-
-  test('bullet list 5', () => {
-    const comment =
-      'To see this bug, you have to:\n' +
-      '* Be on IMAP or EAS (not on POP)\n' +
-      '* Be very unlucky\n';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertSimpleTextBlock(result[0], 'To see this bug, you have to:');
-    assertListBlock(result[1], [
-      {spans: [{type: 'text', text: 'Be on IMAP or EAS (not on POP)'}]},
-      {spans: [{type: 'text', text: 'Be very unlucky'}]},
-    ]);
-  });
-
-  test('bullet list 6', () => {
-    const comment =
-      'To see this bug,\n' +
-      'you have to:\n' +
-      '* Be on IMAP or EAS (not on POP)\n' +
-      '* Be very unlucky\n';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertSimpleTextBlock(result[0], 'To see this bug,\nyou have to:');
-    assertListBlock(result[1], [
-      {spans: [{type: 'text', text: 'Be on IMAP or EAS (not on POP)'}]},
-      {spans: [{type: 'text', text: 'Be very unlucky'}]},
-    ]);
-  });
-
-  test('dash list 1', () => {
-    const comment = 'A\n- line 1\n- 2nd line';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertSimpleTextBlock(result[0], 'A');
-    assertListBlock(result[1], [
-      {spans: [{type: 'text', text: 'line 1'}]},
-      {spans: [{type: 'text', text: '2nd line'}]},
-    ]);
-  });
-
-  test('dash list 2', () => {
-    const comment = 'A\n- line 1\n- 2nd line\n\nB';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 3);
-    assertSimpleTextBlock(result[0], 'A');
-    assertListBlock(result[1], [
-      {spans: [{type: 'text', text: 'line 1'}]},
-      {spans: [{type: 'text', text: '2nd line'}]},
-    ]);
-    assertSimpleTextBlock(result[2], 'B');
-  });
-
-  test('dash list 3', () => {
-    const comment = '- line 1\n- 2nd line\n\nB';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertListBlock(result[0], [
-      {spans: [{type: 'text', text: 'line 1'}]},
-      {spans: [{type: 'text', text: '2nd line'}]},
-    ]);
-    assertSimpleTextBlock(result[1], 'B');
-  });
-
-  test('list with links', () => {
-    const comment = '- [text](http://url)\n- 2nd line';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertListBlock(result[0], [
-      {
-        spans: [{type: 'link', text: 'text', url: 'http://url'}],
+    await setCommentLinks({
+      customLinkRewrite: {
+        match: '(LinkRewriteMe)',
+        link: 'http://google.com/$1',
       },
-      {spans: [{type: 'text', text: '2nd line'}]},
-    ]);
+      customHtmlRewrite: {
+        match: 'HTMLRewriteMe',
+        html: '<div>HTMLRewritten</div>',
+      },
+    });
+    element = (
+      await fixture(
+        wrapInProvider(
+          html`<gr-formatted-text></gr-formatted-text>`,
+          configModelToken,
+          configModel
+        )
+      )
+    ).querySelector('gr-formatted-text')!;
   });
 
-  test('nested list will NOT be recognized', () => {
-    // will be rendered as two separate lists
-    const comment = '- line 1\n    - line with indentation\n- line 2';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 3);
-    assertListBlock(result[0], [{spans: [{type: 'text', text: 'line 1'}]}]);
-    assertPreBlock(result[1], '    - line with indentation');
-    assertListBlock(result[2], [{spans: [{type: 'text', text: 'line 2'}]}]);
+  suite('as plaintext', () => {
+    setup(async () => {
+      element.markdown = false;
+      await element.updateComplete;
+    });
+
+    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>
+          </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('pre format 1', () => {
-    const comment = 'A\n    This is pre\n    formatted';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertSimpleTextBlock(result[0], 'A');
-    assertPreBlock(result[1], '    This is pre\n    formatted');
-  });
+  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;
 
-  test('pre format 2', () => {
-    const comment = 'A\n    This is pre\n    formatted\n\nbut this is not';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 3);
-    assertSimpleTextBlock(result[0], 'A');
-    assertPreBlock(result[1], '    This is pre\n    formatted');
-    assertSimpleTextBlock(result[2], 'but this is not');
-  });
+      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>
+              <div>HTMLRewritten</div>
+              <p></p>
+            </div>
+          </marked-element>
+        `
+      );
+    });
 
-  test('pre format 3', () => {
-    const comment = 'A\n    Q\n    <R>\n    S\n\nB';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 3);
-    assertSimpleTextBlock(result[0], 'A');
-    assertPreBlock(result[1], '    Q\n    <R>\n    S');
-    assertSimpleTextBlock(result[2], 'B');
-  });
+    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;
 
-  test('pre format 4', () => {
-    const comment = '    Q\n    <R>\n    S\n\nB';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertPreBlock(result[0], '    Q\n    <R>\n    S');
-    assertSimpleTextBlock(result[1], 'B');
-  });
+      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('pre format 5', () => {
-    const comment = '    Q\n    <R>\n    S\n \nB';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertPreBlock(result[0], '    Q\n    <R>\n    S');
-    assertSimpleTextBlock(result[1], ' \nB');
-  });
+    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;
 
-  test('pre format 6', () => {
-    const comment = '    Q\n    <R>\n\n    S\n \nB';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertPreBlock(result[0], '    Q\n    <R>\n\n    S');
-    assertSimpleTextBlock(result[1], ' \nB');
-  });
+      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;
 
-  test('quote 1', () => {
-    const comment = "> I'm happy with quotes!!";
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    const quoteBlock = assertQuoteBlock(result[0]);
-    assert.lengthOf(quoteBlock.blocks, 1);
-    assertSimpleTextBlock(quoteBlock.blocks[0], "I'm happy with quotes!!");
-  });
+      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('quote 2', () => {
-    const comment = "> I'm happy\n > with quotes!\n\nSee above.";
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    const quoteBlock = assertQuoteBlock(result[0]);
-    assert.lengthOf(quoteBlock.blocks, 1);
-    assertSimpleTextBlock(quoteBlock.blocks[0], "I'm happy\nwith quotes!");
-    assertSimpleTextBlock(result[1], 'See above.');
-  });
+    test('does not render inline images into <img> tags', async () => {
+      element.content = '![img](google.com/img.png)';
+      await element.updateComplete;
 
-  test('quote 3', () => {
-    const comment = 'See this said:\n > a quoted\n > string block\n\nOK?';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 3);
-    assertSimpleTextBlock(result[0], 'See this said:');
-    const quoteBlock = assertQuoteBlock(result[1]);
-    assert.lengthOf(quoteBlock.blocks, 1);
-    assertSimpleTextBlock(quoteBlock.blocks[0], 'a quoted\nstring block');
-    assertSimpleTextBlock(result[2], 'OK?');
-  });
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html">
+              <p>![img](google.com/img.png)</p>
+            </div>
+          </marked-element>
+        `
+      );
+    });
 
-  test('nested quotes', () => {
-    const comment = ' > > prior\n > \n > next\n';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    const outerQuoteBlock = assertQuoteBlock(result[0]);
-    assert.lengthOf(outerQuoteBlock.blocks, 2);
-    const nestedQuoteBlock = assertQuoteBlock(outerQuoteBlock.blocks[0]);
-    assert.lengthOf(nestedQuoteBlock.blocks, 1);
-    assertSimpleTextBlock(nestedQuoteBlock.blocks[0], 'prior');
-    assertSimpleTextBlock(outerQuoteBlock.blocks[1], 'next');
-  });
+    test('renders inline links into <a> tags', async () => {
+      element.content = '[myLink](https://www.google.com)';
+      await element.updateComplete;
 
-  test('code entire text', () => {
-    const comment = '```\n// test code\n```';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertCodeBlock(result[0], '// test code');
-  });
+      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('code first line is descriptor not part of code', () => {
-    const comment = 'test code\n```descr\n// test code\n```\nsomething else';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 3);
-    assertSimpleTextBlock(result[0], 'test code');
-    // 'descr' is omitted.
-    assertCodeBlock(result[1], '// test code');
-    assertSimpleTextBlock(result[2], 'something else');
-  });
+    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;
 
-  test('code open without close eats everything', () => {
-    const comment = 'test code\n```\n// test code\n// more code';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertSimpleTextBlock(result[0], 'test code');
-    assertCodeBlock(result[1], '// test code\n// more code');
-  });
+      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('backticks inside line not code', () => {
-    const comment = 'test code\nwords ```\n// test code```';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    // We don't care how paragraph itself is parsed for this test.
-    assert.equal(result[0].type, 'paragraph');
-  });
+    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;
 
-  test('mix all 1', () => {
-    const comment =
-      '    bullets:\n- bullet 1\n- bullet 2\n\ncode example:\n' +
-      '```\n// test code\n```\n\n> reference is here';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 5);
-    assert.equal(result[0].type, 'pre');
-    assert.equal(result[1].type, 'list');
-    assert.equal(result[2].type, 'paragraph');
-    assert.equal(result[3].type, 'code');
-    assert.equal(result[4].type, 'quote');
-  });
-
-  test('text with \\t is paragraph', () => {
-    const comment =
-      "Changes to NoteDb or entities packages require careful consideration. 		      Make sure your change is forward compatible and add the footer 		      'Forward-Compatible: checked' to your commit message";
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assert.equal(result[0].type, 'paragraph');
+      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>
+        `
+      );
+    });
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-markdown/gr-markdown.ts b/polygerrit-ui/app/elements/shared/gr-markdown/gr-markdown.ts
deleted file mode 100644
index c315603..0000000
--- a/polygerrit-ui/app/elements/shared/gr-markdown/gr-markdown.ts
+++ /dev/null
@@ -1,211 +0,0 @@
-/**
- * @license
- * Copyright 2022 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {css, html, LitElement} from 'lit';
-import {customElement, property, state} from 'lit/decorators.js';
-import {
-  htmlEscape,
-  sanitizeHtml,
-  sanitizeHtmlToFragment,
-} from '../../../utils/inner-html-util';
-import {unescapeHTML} from '../../../utils/syntax-util';
-import '@polymer/marked-element';
-import {resolve} from '../../../models/dependency';
-import {subscribe} from '../../lit/subscription-controller';
-import {configModelToken} from '../../../models/config/config-model';
-import {CommentLinks} from '../../../api/rest-api';
-import {
-  applyHtmlRewritesFromConfig,
-  applyLinkRewritesFromConfig,
-  linkifyNormalUrls,
-} from '../../../utils/link-util';
-
-/**
- * This element renders markdown and also applies some regex replacements to
- * linkify key parts of the text defined by the host's config.
- *
- * TODO: Replace gr-formatted-text with this once markdown flag is rolled out.
- */
-@customElement('gr-markdown')
-export class GrMarkdown extends LitElement {
-  @property({type: String})
-  content = '';
-
-  @property({type: Boolean})
-  markdown = false;
-
-  @state()
-  private repoCommentLinks: CommentLinks = {};
-
-  private readonly getConfigModel = resolve(this, configModelToken);
-
-  /**
-   * Note: Do not use sharedStyles or other styles here that should not affect
-   * the generated HTML of the markdown.
-   */
-  static override styles = [
-    css`
-      a {
-        color: var(--link-color);
-      }
-      p,
-      ul,
-      code,
-      blockquote {
-        margin: 0 0 var(--spacing-m) 0;
-        max-width: var(--gr-formatted-text-prose-max-width, none);
-      }
-      p:last-child,
-      ul:last-child,
-      blockquote:last-child,
-      pre:last-child {
-        margin: 0;
-      }
-      blockquote {
-        border-left: var(--spacing-xxs) solid var(--comment-quote-marker-color);
-        padding: 0 var(--spacing-m);
-      }
-      code {
-        background-color: var(--background-color-secondary);
-        border: var(--spacing-xxs) solid var(--border-color);
-        display: block;
-        font-family: var(--monospace-font-family);
-        font-size: var(--font-size-code);
-        line-height: var(--line-height-mono);
-        margin: var(--spacing-m) 0;
-        padding: var(--spacing-xxs) var(--spacing-s);
-        overflow-x: auto;
-        /* Pre will preserve whitespace and line breaks but not wrap */
-        white-space: pre;
-      }
-      /* Non-multiline code elements need display:inline to shrink and not take
-         a whole row */
-      :not(pre) > code {
-        display: inline;
-      }
-      p {
-        /* prose will automatically wrap but inline <code> blocks won't and we
-           should overflow in that case rather than wrapping or leaking out */
-        overflow-x: auto;
-      }
-      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);
-      }
-    `,
-  ];
-
-  constructor() {
-    super();
-    subscribe(
-      this,
-      () => this.getConfigModel().repoCommentLinks$,
-      repoCommentLinks => (this.repoCommentLinks = repoCommentLinks)
-    );
-  }
-
-  override render() {
-    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.
-    const boundRewriteText = (text: string) =>
-      this.rewriteText(text, this.repoCommentLinks);
-
-    // We are overriding some marked-element renderers for a few reasons:
-    // 1. Disable inline images as a design/policy choice.
-    // 2. Inline code blocks ("codespan") do not unescape HTML characters when
-    //    rendering without <pre> and so we must do this manually.
-    //    <marked-element> is already escaping these internally. See test
-    //    covering this.
-    // 3. Multiline code blocks ("code") is similarly handling escaped
-    //    characters using <pre>. The convention is to only use <pre> for multi-
-    //    line code blocks so it is not used for inline code blocks. See test
-    //    for this.
-    // 4. Rewrite plain text ("text") to apply linking and other config-based
-    //    rewrites. Text within code blocks is not passed here.
-    function customRenderer(renderer: {[type: string]: Function}) {
-      renderer['image'] = (href: string, _title: string, text: string) =>
-        `![${text}](${href})`;
-      renderer['codespan'] = (text: string) =>
-        `<code>${unescapeHTML(text)}</code>`;
-      renderer['code'] = (text: string) => `<pre><code>${text}</code></pre>`;
-      renderer['text'] = boundRewriteText;
-    }
-
-    // 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.content)}
-        .breaks=${true}
-        .renderer=${customRenderer}
-        .callback=${(_error: string | null, contents: string) =>
-          sanitizeHtml(contents)}
-      >
-        <div slot="markdown-html"></div>
-      </marked-element>
-    `;
-  }
-
-  private escapeAllButBlockQuotes(text: string) {
-    // Escaping the message should be done first to make sure user's literal
-    // input does not get rendered without affecting html added in later steps.
-    text = htmlEscape(text).toString();
-    // Unescape block quotes '>'. This is slightly dangerous as '>' can be used
-    // in HTML fragments, but it is insufficient on it's own.
-    text = text.replace(/(^|\n)&gt;/g, '$1>');
-
-    return text;
-  }
-
-  private rewriteText(text: string, repoCommentLinks: CommentLinks) {
-    // Turn universally identifiable URLs into links. Ex: www.google.com. The
-    // markdown library inside marked-element does this too, but is more
-    // conservative and misses some URLs like "google.com" without "www" prefix.
-    text = linkifyNormalUrls(text);
-
-    // Apply the host's config-specific regex replacements to create links. Ex:
-    // link "Bug 12345" to "google.com/bug/12345"
-    text = applyLinkRewritesFromConfig(text, repoCommentLinks);
-
-    // Apply the host's config-specific regex replacements to write arbitrary
-    // html. Most examples seen in the wild are also used for linking but with
-    // finer control over the rendered text. Ex: "Bug 12345" => "#12345"
-    text = applyHtmlRewritesFromConfig(text, repoCommentLinks);
-
-    return text;
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-markdown': GrMarkdown;
-  }
-}
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
deleted file mode 100644
index 2327dfe..0000000
--- a/polygerrit-ui/app/elements/shared/gr-markdown/gr-markdown_test.ts
+++ /dev/null
@@ -1,383 +0,0 @@
-/**
- * @license
- * Copyright 2022 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../../test/common-test-setup';
-import {assert, fixture, html} from '@open-wc/testing';
-import {changeModelToken} from '../../../models/change/change-model';
-import {
-  ConfigModel,
-  configModelToken,
-} from '../../../models/config/config-model';
-import {wrapInProvider} from '../../../models/di-provider-element';
-import {getAppContext} from '../../../services/app-context';
-import './gr-markdown';
-import {GrMarkdown} from './gr-markdown';
-import {createConfig} from '../../../test/test-data-generators';
-import {waitUntilObserved} from '../../../test/test-utils';
-import {CommentLinks} from '../../../api/rest-api';
-import {testResolver} from '../../../test/common-test-setup';
-
-suite('gr-markdown tests', () => {
-  let element: GrMarkdown;
-  let configModel: ConfigModel;
-
-  async function setCommentLinks(commentlinks: CommentLinks) {
-    configModel.updateRepoConfig({...createConfig(), commentlinks});
-    await waitUntilObserved(
-      configModel.repoCommentLinks$,
-      links => links === commentlinks
-    );
-  }
-
-  setup(async () => {
-    configModel = new ConfigModel(
-      testResolver(changeModelToken),
-      getAppContext().restApiService
-    );
-    await setCommentLinks({
-      customLinkRewrite: {
-        match: '(LinkRewriteMe)',
-        link: 'http://google.com/$1',
-      },
-      customHtmlRewrite: {
-        match: 'HTMLRewriteMe',
-        html: '<div>HTMLRewritten</div>',
-      },
-    });
-    element = (
-      await fixture(
-        wrapInProvider(
-          html`<gr-markdown></gr-markdown>`,
-          configModelToken,
-          configModel
-        )
-      )
-    ).querySelector('gr-markdown')!;
-  });
-
-  suite('as plaintext', () => {
-    setup(async () => {
-      element.markdown = false;
-      await element.updateComplete;
-    });
-
-    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>
-          </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>'
-      );
-    });
-  });
-
-  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">
-              <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>
-              <div>HTMLRewritten</div>
-              <p></p>
-            </div>
-          </marked-element>
-        `
-      );
-    });
-
-    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;
-
-      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>
-        `
-      );
-    });
-  });
-});
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 1637296..6caeb62 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-markdown content="' +
-          '[[_computeCurrentRevisionMessage(change)]]"></gr-markdown>',
+        '        <gr-formatted-text content="' +
+          '[[_computeCurrentRevisionMessage(change)]]"></gr-formatted-text>',
         '      </section>',
       ];
       let highlights = [
@@ -659,13 +659,9 @@
         },
         {
           contentIndex: 1,
+          endIndex: 101,
           startIndex: 75,
         },
-        {
-          contentIndex: 2,
-          startIndex: 0,
-          endIndex: 12,
-        },
       ]);
       const lines = element.linesFromRows(
         GrDiffLineType.BOTH,
@@ -679,7 +675,7 @@
       assert.isTrue(lines[1].hasIntralineInfo);
       assert.equal(lines[1].highlights.length, 2);
       assert.isTrue(lines[2].hasIntralineInfo);
-      assert.equal(lines[2].highlights.length, 1);
+      assert.equal(lines[2].highlights.length, 0);
 
       content = [
         '        this._path = value.path;',
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 89a0756..8acaf04 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,9 @@
         <div data-side="left">
           <div class="comment-thread">
             <div class="gr-formatted-text message">
-              <span id="output" class="gr-markdown">This is a comment</span>
+              <span id="output" class="gr-formatted-text"
+                >This is a comment</span
+              >
             </div>
           </div>
         </div>
@@ -44,7 +46,7 @@
         <div data-side="right">
           <div class="comment-thread">
             <div class="gr-formatted-text message">
-              <span id="output" class="gr-markdown"
+              <span id="output" class="gr-formatted-text"
                 >This is a comment on the right</span
               >
             </div>
@@ -60,7 +62,7 @@
         <div data-side="left">
           <div class="comment-thread">
             <div class="gr-formatted-text message">
-              <span id="output" class="gr-markdown"
+              <span id="output" class="gr-formatted-text"
                 >This is <a>a</a> different comment 💩 unicode is fun</span
               >
             </div>