diff options
author | Frank Borden <frankborden@google.com> | 2022-09-29 14:22:04 +0200 |
---|---|---|
committer | Frank Borden <frankborden@google.com> | 2022-09-29 14:22:04 +0200 |
commit | c2d5c1bec371aff30d15d53225001d0c1a75e1b3 (patch) | |
tree | 0ca0aafc0b0f6b92bf6f0572984629fca6f3884f | |
parent | 76cf8105b225f89d1b05faab6c11ff742d2164e0 (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
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); + } +} |