Files
gerrit/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts
Tao Zhou 38b3e0da92 Replace all usage of bind() with arrow functions
This left all test files untouched.

Change-Id: I677383d51adb5bf8ca7fd5f18f16c34eca8b3498
2020-09-09 17:02:21 +02:00

429 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @license
* Copyright (C) 2015 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
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.
*
* @constructor
* @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 p in 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 (config[p].enabled != null && config[p].enabled == false) {
continue;
}
// PolyGerrit doesn't use hash-based navigation like the GWT UI.
// Account for this.
const html = config[p].html;
const link = config[p].link;
if (html) {
config[p].html = html.replace(/<a href="#\//g, '<a href="/');
} else if (link) {
if (link[0] === '#') {
config[p].link = link.substr(1);
}
}
const pattern = new RegExp(config[p].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.
(config[p].html || config[p].link)!
);
if (config[p].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 (config[p].link) {
this.addLink(
match[0],
result,
susbtrIndex + match.index,
match[0].length,
outputArray
);
} else {
throw Error(
'linkconfig entry ' +
p +
' doesnt 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);
}
}