summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorFrank Borden <frankborden@google.com>2022-09-29 14:22:04 +0200
committerFrank Borden <frankborden@google.com>2022-09-29 14:22:04 +0200
commitc2d5c1bec371aff30d15d53225001d0c1a75e1b3 (patch)
tree0ca0aafc0b0f6b92bf6f0572984629fca6f3884f
parent76cf8105b225f89d1b05faab6c11ff742d2164e0 (diff)
restore gr-linked-text and link-text-parser
There have been several reports since my rewrite does not match the old semantics. Release-Notes: skip Change-Id: I5fe93ff9caf54bbdd0b9e56b7004b84138dccb0a
-rw-r--r--polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts33
-rw-r--r--polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts18
-rw-r--r--polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts5
-rw-r--r--polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts178
-rw-r--r--polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts471
-rw-r--r--polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts415
6 files changed, 1109 insertions, 11 deletions
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 76ec316a9e..f2977dc3ea 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-button/gr-button';
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-formatted-text/gr-formatted-text';
+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 @@ import {createEditUrl} from '../../../models/views/edit';
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;
@@ -958,7 +959,7 @@ export class GrChangeView extends LitElement {
/* Account for border and padding and rounding errors. */
max-width: calc(72ch + 2px + 2 * var(--spacing-m) + 0.4px);
}
- .commitMessage gr-formatted-text {
+ .commitMessage gr-linked-text {
word-break: break-word;
}
#commitMessageEditor {
@@ -1459,10 +1460,12 @@ export class GrChangeView extends LitElement {
.commitCollapsible=${this.computeCommitCollapsible()}
remove-zero-width-space=""
>
- <gr-formatted-text
- .markdown=${false}
- .content=${this.latestCommitMessage ?? ''}
- ></gr-formatted-text>
+ <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>
@@ -1821,7 +1824,7 @@ export class GrChangeView extends LitElement {
return;
}
- this.latestCommitMessage = message;
+ this.latestCommitMessage = this.prepareCommitMsgForLinkify(message);
this.editingCommitMessage = false;
this.reloadWindow();
})
@@ -2671,6 +2674,14 @@ export class GrChangeView extends LitElement {
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.
@@ -2800,7 +2811,9 @@ export class GrChangeView extends LitElement {
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;
}
@@ -2853,7 +2866,9 @@ export class GrChangeView extends LitElement {
.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 e0c09e2193..ad84fb02bf 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,9 @@ suite('gr-change-view tests', () => {
id="commitMessageEditor"
remove-zero-width-space=""
>
- <gr-formatted-text></gr-formatted-text>
+ <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">
@@ -1407,6 +1409,20 @@ suite('gr-change-view tests', () => {
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/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
index 8b651f147b..d6a4d94573 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 @@ export class GrEditableContent extends LitElement {
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-linked-text/gr-linked-text.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts
new file mode 100644
index 0000000000..16a60e7007
--- /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 0000000000..00e03138aa
--- /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 0000000000..73cf58bd5d
--- /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);
+ }
+}