Revert "Merge gr-linked-text into gr-markdown"
This reverts commit 3739c0a73b8576828538d6c0dfd2f1a9380b575b.
Reason for revert: breaks google import
Trivial modification needed to account for
https://gerrit-review.googlesource.com/c/gerrit/+/346154
see PS1 vs PS2.
Release-Notes: skip
Change-Id: I470cca1266bdebbb4b0d1fa590ba74d3ef8f1547
diff --git a/modules/jgit b/modules/jgit
index 21a4978..85182df 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit 21a497843cb804f5e714d8c93526422639efcc80
+Subproject commit 85182df26779dd6373d188b6adc936454b4c9189
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 b482b82..7d7d8b8 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-linked-text/gr-linked-text';
import '../../shared/gr-overlay/gr-overlay';
import '../../shared/gr-tooltip-content/gr-tooltip-content';
import '../gr-change-actions/gr-change-actions';
@@ -191,6 +191,7 @@
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;
@@ -956,7 +957,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-linked-text {
word-break: break-word;
}
#commitMessageEditor {
@@ -1457,9 +1458,12 @@
.commitCollapsible=${this.computeCommitCollapsible()}
remove-zero-width-space=""
>
- <gr-markdown
- .content=${this.latestCommitMessage ?? ''}
- ></gr-markdown>
+ <gr-linked-text
+ pre=""
+ .content=${this.latestCommitMessage}
+ .config=${this.projectConfig?.commentlinks}
+ remove-zero-width-space=""
+ ></gr-linked-text>
</gr-editable-content>
</div>
<h3 class="assistive-tech-only">Comments and Checks Summary</h3>
@@ -1818,7 +1822,7 @@
return;
}
- this.latestCommitMessage = message;
+ this.latestCommitMessage = this.prepareCommitMsgForLinkify(message);
this.editingCommitMessage = false;
this.reloadWindow();
})
@@ -2659,6 +2663,14 @@
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.
@@ -2788,7 +2800,9 @@
throw new Error('Could not find latest Revision Sha');
const currentRevision = this.change.revisions[latestRevisionSha];
if (currentRevision.commit && currentRevision.commit.message) {
- this.latestCommitMessage = currentRevision.commit.message;
+ this.latestCommitMessage = this.prepareCommitMsgForLinkify(
+ currentRevision.commit.message
+ );
} else {
this.latestCommitMessage = null;
}
@@ -2841,7 +2855,9 @@
.getChangeCommitInfo(this.changeNum, lastpatchNum)
.then(commitInfo => {
if (!commitInfo) return;
- this.latestCommitMessage = commitInfo.message;
+ this.latestCommitMessage = this.prepareCommitMsgForLinkify(
+ 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 7ec34d6..0e6fb71 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
@@ -435,7 +435,9 @@
id="commitMessageEditor"
remove-zero-width-space=""
>
- <gr-markdown></gr-markdown>
+ <gr-linked-text pre="" remove-zero-width-space="">
+ <span id="output" slot="insert"> </span>
+ </gr-linked-text>
</gr-editable-content>
</div>
<h3 class="assistive-tech-only">
@@ -1424,6 +1426,20 @@
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 99e99bb..d2b840f 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,6 +12,7 @@
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 8b651f1..d6a4d94 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,7 +370,10 @@
content = this.content || '';
}
- this.newContent = content;
+ // TODO(wyatta) switch linkify sequence, see issue 5526.
+ this.newContent = this.removeZeroWidthSpace
+ ? content.replace(/^R=\u200B/gm, 'R=')
+ : 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 fec347c6..6389955 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,6 +140,25 @@
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 11e7152..23b4e81 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,6 +3,7 @@
* 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';
@@ -90,7 +91,7 @@
ul,
code,
blockquote,
- gr-markdown.pre {
+ gr-linked-text.pre {
margin: 0 0 var(--spacing-m) 0;
}
p,
@@ -102,7 +103,7 @@
:host([noTrailingMargin]) p:last-child,
:host([noTrailingMargin]) ul:last-child,
:host([noTrailingMargin]) blockquote:last-child,
- :host([noTrailingMargin]) gr-markdown.pre:last-child {
+ :host([noTrailingMargin]) gr-linked-text.pre:last-child {
margin: 0;
}
blockquote {
@@ -141,10 +142,7 @@
if (!this.content) return;
if (this.flagsService.isEnabled(KnownExperimentId.RENDER_MARKDOWN)) {
- return html`<gr-markdown
- .markdown=${true}
- .content=${this.content}
- ></gr-markdown>`;
+ return html`<gr-markdown .markdown=${this.content}></gr-markdown>`;
} else {
const blocks = this._computeBlocks(this.content);
return html`${blocks.map(block => this.renderBlock(block))}`;
@@ -356,7 +354,14 @@
}
private renderInlineText(content: string): TemplateResult {
- return html`<gr-markdown .content=${content}></gr-markdown>`;
+ return html`
+ <gr-linked-text
+ .config=${this.config}
+ content=${content}
+ pre
+ inline
+ ></gr-linked-text>
+ `;
}
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 62cb7c8..191886e 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,7 +103,9 @@
element,
/* HTML */ `
<p>
- <gr-markdown></gr-markdown>
+ <gr-linked-text content="text " inline="" pre="">
+ <span id="output" slot="insert"> text </span>
+ </gr-linked-text>
<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
new file mode 100644
index 0000000..16a60e7
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts
@@ -0,0 +1,178 @@
+/**
+ * @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
new file mode 100644
index 0000000..00e0313
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts
@@ -0,0 +1,471 @@
+/**
+ * @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
new file mode 100644
index 0000000..73cf58b
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts
@@ -0,0 +1,415 @@
+/**
+ * @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 68f360d..ae4b004 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,6 @@
*/
import {css, html, LitElement} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
-import {unsafeHTML} from 'lit/directives/unsafe-html.js';
import {htmlEscape} from '../../../utils/inner-html-util';
import {unescapeHTML} from '../../../utils/syntax-util';
import '@polymer/marked-element';
@@ -23,15 +22,12 @@
* 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.
+ * TODO: Remove gr-formatted-text once this is rolled out.
*/
@customElement('gr-markdown')
export class GrMarkdown extends LitElement {
@property({type: String})
- content = '';
-
- @property({type: Boolean})
- markdown = false;
+ markdown?: string;
@state()
private repoCommentLinks: CommentLinks = {};
@@ -90,11 +86,6 @@
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);
- }
`,
];
@@ -108,23 +99,9 @@
}
override render() {
- if (this.markdown) {
- return this.renderAsMarkdown();
- } else {
- return this.renderAsPlaintext();
- }
- }
+ // Note: Handling \u200B added in gr-change-view.ts is not needed here
+ // because the commit message is not markdown formatted.
- private renderAsPlaintext() {
- const linkedText = this.rewriteText(
- htmlEscape(this.content).toString(),
- this.repoCommentLinks
- );
-
- return html`<pre class="plaintext">${unsafeHTML(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.
@@ -155,7 +132,7 @@
// The child with slot is optional but allows us control over the styling.
return html`
<marked-element
- .markdown=${this.escapeAllButBlockQuotes(this.content)}
+ .markdown=${this.escapeAllButBlockQuotes(this.markdown ?? '')}
.breaks=${true}
.renderer=${customRenderer}
>
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 bb03f91..1128b3a 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,327 +57,264 @@
).querySelector('gr-markdown')!;
});
- suite('as plaintext', () => {
- setup(async () => {
- element.markdown = false;
- await element.updateComplete;
- });
+ 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;
- 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:
+ 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>
- </pre>
- `
- );
- });
-
- test('does not render typed html', async () => {
- element.content = 'plain text <div>foo</div>';
- await element.updateComplete;
-
- const escapedDiv = '<div>foo</div>';
- 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>'
- );
- });
+ <p></p>
+ </div>
+ </marked-element>
+ `
+ );
});
- 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('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;
- 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>
+ 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>
- <p></p>
- </div>
- </marked-element>
- `
- );
- });
+ </h1>
+ </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;
+ 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">
- <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>
- `
- );
- });
+ 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;
- 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>
+ assert.shadowDom.equal(
+ element,
+ /* HTML */ `
+ <marked-element>
+ <div slot="markdown-html">
+ <pre>
<code> multiline code </code>
</pre>
- <pre>
+ <pre>
<code> multiline code with plain link: google.com </code>
</pre>
- <pre>
+ <pre>
<code> multiline code with config link: LinkRewriteMe </code>
</pre>
- <pre>
+ <pre>
<code> multiline code with config html: HTMLRewriteMe </code>
</pre>
- </div>
- </marked-element>
- `
- );
- });
+ </div>
+ </marked-element>
+ `
+ );
+ });
- test('does not render inline images into <img> tags', async () => {
- element.content = '';
- await element.updateComplete;
+ test('does not render inline images into <img> tags', async () => {
+ element.markdown = '';
+ await element.updateComplete;
- assert.shadowDom.equal(
- element,
- /* HTML */ `
- <marked-element>
- <div slot="markdown-html">
- <p></p>
- </div>
- </marked-element>
- `
- );
- });
+ assert.shadowDom.equal(
+ element,
+ /* HTML */ `
+ <marked-element>
+ <div slot="markdown-html">
+ <p></p>
+ </div>
+ </marked-element>
+ `
+ );
+ });
- test('renders inline links into <a> tags', async () => {
- element.content = '[myLink](https://www.google.com)';
- await element.updateComplete;
+ 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">
+ 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>
<p>
- <a href="https://www.google.com">myLink</a>
+ block quote with plain link:
+ <a href="http://google.com" rel="noopener" target="_blank">
+ google.com
+ </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 = '<div>foo</div>';
- assert.shadowDom.equal(
- element,
- /* HTML */ `
- <marked-element>
- <div slot="markdown-html">
- <p>plain text ${escapedDiv}</p>
+ </blockquote>
+ <blockquote>
<p>
- <code> inline code ${escapedDiv} </code>
+ block quote with config link:
+ <a
+ href="http://google.com/LinkRewriteMe"
+ rel="noopener"
+ target="_blank"
+ >
+ LinkRewriteMe
+ </a>
</p>
- <pre>
+ </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.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;
+
+ const escapedDiv = '<div>foo</div>';
+ 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 1637296..d958ef4 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-linked-text content="' +
+ '[[_computeCurrentRevisionMessage(change)]]"></gr-linked-text>',
' </section>',
];
let highlights = [
@@ -664,7 +664,7 @@
{
contentIndex: 2,
startIndex: 0,
- endIndex: 12,
+ endIndex: 6,
},
]);
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 89a0756..cb3eed5 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-markdown">This is a comment</span>
+ <span id="output" class="gr-linked-text">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-markdown"
+ <span id="output" class="gr-linked-text"
>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-markdown"
+ <span id="output" class="gr-linked-text"
>This is <a>a</a> different comment 💩 unicode is fun</span
>
</div>
diff --git a/polygerrit-ui/app/utils/link-util.ts b/polygerrit-ui/app/utils/link-util.ts
index fd5965b..223f780 100644
--- a/polygerrit-ui/app/utils/link-util.ts
+++ b/polygerrit-ui/app/utils/link-util.ts
@@ -8,20 +8,10 @@
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(baseWithZeroWidthSpace, {
- callback: (text, href) => {
- const result = href ? createLinkTemplate(text, href) : text;
- const resultWithoutZeroWidthSpace = result.replace(/\u200B/g, '');
- parts.push(resultWithoutZeroWidthSpace);
- },
+ window.linkify(base, {
+ callback: (text, href) =>
+ parts.push(href ? createLinkTemplate(text, href) : text),
});
return parts.join('');
}
diff --git a/polygerrit-ui/app/utils/link-util_test.ts b/polygerrit-ui/app/utils/link-util_test.ts
index c491e35..ecbcb61 100644
--- a/polygerrit-ui/app/utils/link-util_test.ts
+++ b/polygerrit-ui/app/utils/link-util_test.ts
@@ -47,45 +47,13 @@
`${linkedNumber} ${linkedFoo}`
);
});
+ test('linkifyNormalUrls', () => {
+ const googleLink = link('google.com', 'http://google.com');
+ const mapsLink = link('maps.google.com', 'http://maps.google.com');
- 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
- );
- });
+ assert.equal(
+ linkifyNormalUrls('google.com, maps.google.com'),
+ `${googleLink}, ${mapsLink}`
+ );
});
});