Files
gerrit/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
Paladox 84df883183 Merge branch 'stable-2.15' into stable-2.16
* stable-2.15:
  Fix regex in link-text-parser

Change-Id: I645413967c12310e7dbf610bce4867446d66281f
2019-09-30 17:08:02 +01:00

338 lines
12 KiB
JavaScript
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.
*/
(function() {
'use strict';
const Defs = {};
/**
* @typedef {{
* html: Node,
* position: number,
* length: number,
* }}
*/
Defs.CommentLinkItem;
/**
* Pattern describing URLs with supported protocols.
* @type {RegExp}
*/
const URL_PROTOCOL_PATTERN = /^(https?:\/\/|mailto:)/;
/**
* 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 {Object|null|undefined} linkConfig Comment links as specified by the
* commentlinks field on a project config.
* @param {Function} 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 {boolean|undefined} opt_removeZeroWidthSpace If true, zero-width
* spaces will be removed from R=<email> and CC=<email> expressions.
*/
function GrLinkTextParser(linkConfig, callback, opt_removeZeroWidthSpace) {
this.linkConfig = linkConfig;
this.callback = callback;
this.removeZeroWidthSpace = opt_removeZeroWidthSpace;
this.baseUrl = Gerrit.BaseUrlBehavior.getBaseUrl();
Object.preventExtensions(this);
}
/**
* Emit a callback to create a link element.
* @param {string} text The text of the link.
* @param {string} href The URL to use as the href of the link.
*/
GrLinkTextParser.prototype.addText = function(text, href) {
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 {string} text The chuml of source text over which the outputArray
* items range.
* @param {!Array<Defs.CommentLinkItem>} outputArray The list of items to add
* resulting from commentlink matches.
*/
GrLinkTextParser.prototype.processLinks = function(text, outputArray) {
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.
* @param {!Array<Defs.CommentLinkItem>} outputArray
*/
GrLinkTextParser.prototype.sortArrayReverse = function(outputArray) {
outputArray.sort((a, b) => b.position - a.position);
};
/**
* 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 {string|null} text The text to use if creating a link.
* @param {string|null} href The href to use as the URL if creating a link.
* @param {string|null} html The html to parse and use as the result.
* @param {number} position The position inside the source text where the item
* starts.
* @param {number} length The number of characters in the source text
* represented by the item.
* @param {!Array<Defs.CommentLinkItem>} outputArray The array to which the
* new item is to be appended.
*/
GrLinkTextParser.prototype.addItem =
function(text, href, html, position, length, outputArray) {
let htmlOutput = '';
if (href) {
const a = document.createElement('a');
a.href = href;
a.textContent = text;
a.target = '_blank';
a.rel = 'noopener';
htmlOutput = a;
} else if (html) {
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);
}
htmlOutput = fragment;
}
outputArray.push({
html: htmlOutput,
position,
length,
});
};
/**
* Create a CommentLinkItem for a link and append it to the given output
* array.
* @param {string|null} text The text for the link.
* @param {string|null} href The href to use as the URL of the link.
* @param {number} position The position inside the source text where the link
* starts.
* @param {number} length The number of characters in the source text
* represented by the link.
* @param {!Array<Defs.CommentLinkItem>} outputArray The array to which the
* new item is to be appended.
*/
GrLinkTextParser.prototype.addLink =
function(text, href, position, length, outputArray) {
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 {string|null} html The html to parse and use as the result.
* @param {number} position The position inside the source text where the item
* starts.
* @param {number} length The number of characters in the source text
* represented by the item.
* @param {!Array<Defs.CommentLinkItem>} outputArray The array to which the
* new item is to be appended.
*/
GrLinkTextParser.prototype.addHTML =
function(html, position, length, outputArray) {
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.
* @param {number} position
* @param {number} length
* @param {!Array<Defs.CommentLinkItem>} outputArray
*/
GrLinkTextParser.prototype.hasOverlap =
function(position, length, outputArray) {
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.
* @param {string} text
*/
GrLinkTextParser.prototype.parse = function(text) {
linkify(text, {
callback: this.parseChunk.bind(this),
});
};
/**
* 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.
* @param {string} text
* @param {string|null|undefined} href
*/
GrLinkTextParser.prototype.parseChunk = function(text, href) {
// 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 && URL_PROTOCOL_PATTERN.test(href)) {
this.addText(text, href);
} else {
// 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 {string} text The raw source text.
* @param {Object|null|undefined} patterns A comment links specification
* object.
*/
GrLinkTextParser.prototype.parseLinks = function(text, patterns) {
// The outputArray is used to store all of the matches found for all
// patterns.
const outputArray = [];
for (const p in patterns) {
if (patterns[p].enabled != null && patterns[p].enabled == false) {
continue;
}
// PolyGerrit doesn't use hash-based navigation like the GWT UI.
// Account for this.
if (patterns[p].html) {
patterns[p].html =
patterns[p].html.replace(/<a href=\"#\//g, '<a href="/');
} else if (patterns[p].link) {
if (patterns[p].link[0] == '#') {
patterns[p].link = patterns[p].link.substr(1);
}
}
const pattern = new RegExp(patterns[p].match, 'g');
let match;
let textToCheck = text;
let susbtrIndex = 0;
while ((match = pattern.exec(textToCheck)) != null) {
textToCheck = textToCheck.substr(match.index + match[0].length);
let result = match[0].replace(pattern,
patterns[p].html || patterns[p].link);
let i;
// Skip portion of replacement string that is equal to original.
for (i = 0; i < result.length; i++) {
if (result[i] !== match[0][i]) { break; }
}
result = result.slice(i);
if (patterns[p].html) {
this.addHTML(
result,
susbtrIndex + match.index + i,
match[0].length - i,
outputArray);
} else if (patterns[p].link) {
this.addLink(
match[0],
result,
susbtrIndex + match.index + i,
match[0].length - i,
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);
};
window.GrLinkTextParser = GrLinkTextParser;
})();