Project import generated by Copybara.
GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/autolink.js b/static_src/autolink.js
new file mode 100644
index 0000000..5419d9c
--- /dev/null
+++ b/static_src/autolink.js
@@ -0,0 +1,440 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+'use strict';
+import {prpcClient} from 'prpc-client-instance.js';
+
+/* eslint-disable max-len */
+// When crbug links don't specify a project, the default project is Chromium.
+const CRBUG_DEFAULT_PROJECT = 'chromium';
+const CRBUG_LINK_RE = /(\b(https?:\/\/)?crbug\.com\/)((\b[-a-z0-9]+)(\/))?(\d+)\b(\#c[0-9]+)?/gi;
+const CRBUG_LINK_RE_PROJECT_GROUP = 4;
+const CRBUG_LINK_RE_ID_GROUP = 6;
+const CRBUG_LINK_RE_COMMENT_GROUP = 7;
+const ISSUE_TRACKER_RE = /(\b(issues?|bugs?)[ \t]*(:|=|\b)|\bfixed[ \t]*:)([ \t]*((\b[-a-z0-9]+)[:\#])?(\#?)(\d+)\b(,?[ \t]*(and|or)?)?)+/gi;
+const PROJECT_LOCALID_RE = /((\b(issue|bug)[ \t]*(:|=)?[ \t]*|\bfixed[ \t]*:[ \t]*)?((\b[-a-z0-9]+)[:\#])?(\#?)(\d+))/gi;
+const PROJECT_COMMENT_BUG_RE = /(((\b(issue|bug)[ \t]*(:|=)?[ \t]*)(\#?)(\d+)[ \t*])?((\b((comment)[ \t]*(:|=)?[ \t]*(\#?))|(\B((\#))(c)))(\d+)))/gi;
+const PROJECT_LOCALID_RE_PROJECT_GROUP = 6;
+const PROJECT_LOCALID_RE_ID_GROUP = 8;
+const IMPLIED_EMAIL_RE = /\b[a-z]((-|\.)?[a-z0-9])+@[a-z]((-|\.)?[a-z0-9])+\.(com|net|org|edu|dev)\b/gi;
+// TODO(zhangtiff): Replace (^|[^-/._]) with (?<![-/._]) on the 3 Regexes below
+// once Firefox supports lookaheads.
+const SHORT_LINK_RE = /(^|[^-\/._])\b(https?:\/\/|ftp:\/\/|mailto:)?(go|g|shortn|who|teams)\/([^\s<]+)/gi;
+const NUMERIC_SHORT_LINK_RE = /(^|[^-\/._])\b(https?:\/\/|ftp:\/\/)?(b|t|o|omg|cl|cr|fxr|fxrev|fxb|tqr)\/([0-9]+)/gi;
+const IMPLIED_LINK_RE = /(^|[^-\/._])\b[a-z]((-|\.)?[a-z0-9])+\.(com|net|org|edu|dev)\b(\/[^\s<]*)?/gi;
+const IS_LINK_RE = /()\b(https?:\/\/|ftp:\/\/|mailto:)([^\s<]+)/gi;
+const GIT_HASH_RE = /\b(r(evision\s+#?)?)?([a-f0-9]{40})\b/gi;
+const SVN_REF_RE = /\b(r(evision\s+#?)?)([0-9]{4,7})\b/gi;
+const NEW_LINE_REGEX = /^(\r\n?|\n)$/;
+const NEW_LINE_OR_BOLD_REGEX = /(<b>[^<\n]+<\/b>)|(\r\n?|\n)/;
+// The revNum is in the same position for the above two Regexes. The
+// extraction function uses this similar format to allow switching out
+// Regexes easily, so be careful about changing GIT_HASH_RE and SVN_HASH_RE.
+const REV_NUM_GROUP = 3;
+const LINK_TRAILING_CHARS = [
+ [null, ':'],
+ [null, '.'],
+ [null, ','],
+ [null, '>'],
+ ['(', ')'],
+ ['[', ']'],
+ ['{', '}'],
+ ['\'', '\''],
+ ['"', '"'],
+];
+const GOOG_SHORT_LINK_RE = /^(b|t|o|omg|cl|cr|go|g|shortn|who|teams|fxr|fxrev|fxb|tqr)\/.*/gi;
+/* eslint-enable max-len */
+
+const Components = new Map();
+// TODO(zosha): Combine functions of Component 00 with 01 so that
+// user can only reference valid issues in the issue/comment linking.
+// Allow user to reference multiple comments on the same issue.
+// Additionally, allow for the user to reference this on a specific project.
+// Note: the order of the components is important for proper autolinking.
+Components.set(
+ '00-commentbug',
+ {
+ lookup: null,
+ extractRefs: null,
+ refRegs: [PROJECT_COMMENT_BUG_RE],
+ replacer: ReplaceCommentBugRef,
+ },
+);
+Components.set(
+ '01-tracker-crbug',
+ {
+ lookup: LookupReferencedIssues,
+ extractRefs: ExtractCrbugProjectAndIssueIds,
+ refRegs: [CRBUG_LINK_RE],
+ replacer: ReplaceCrbugIssueRef,
+
+ },
+);
+Components.set(
+ '02-full-urls',
+ {
+ lookup: null,
+ extractRefs: (match, _currentProjectName) => {
+ return [match[0]];
+ },
+ refRegs: [IS_LINK_RE],
+ replacer: ReplaceLinkRef,
+ },
+);
+Components.set(
+ '03-user-emails',
+ {
+ lookup: LookupReferencedUsers,
+ extractRefs: (match, _currentProjectName) => {
+ return [match[0]];
+ },
+ refRegs: [IMPLIED_EMAIL_RE],
+ replacer: ReplaceUserRef,
+ },
+);
+Components.set(
+ '04-tracker-regular',
+ {
+ lookup: LookupReferencedIssues,
+ extractRefs: ExtractTrackerProjectAndIssueIds,
+ refRegs: [ISSUE_TRACKER_RE],
+ replacer: ReplaceTrackerIssueRef,
+ },
+);
+Components.set(
+ '05-linkify-shorthand',
+ {
+ lookup: null,
+ extractRefs: (match, _currentProjectName) => {
+ return [match[0]];
+ },
+ refRegs: [
+ SHORT_LINK_RE,
+ NUMERIC_SHORT_LINK_RE,
+ IMPLIED_LINK_RE,
+ ],
+ replacer: ReplaceLinkRef,
+ },
+);
+Components.set(
+ '06-versioncontrol',
+ {
+ lookup: null,
+ extractRefs: (match, _currentProjectName) => {
+ return [match[0]];
+ },
+ refRegs: [GIT_HASH_RE, SVN_REF_RE],
+ replacer: ReplaceRevisionRef,
+ },
+);
+
+// Lookup referenced artifacts functions.
+function LookupReferencedIssues(issueRefs, componentName) {
+ return new Promise((resolve, reject) => {
+ issueRefs = issueRefs.filter(
+ ({projectName, localId}) => projectName && parseInt(localId));
+ const listReferencedIssues = prpcClient.call(
+ 'monorail.Issues', 'ListReferencedIssues', {issueRefs});
+ return listReferencedIssues.then((response) => {
+ resolve({'componentName': componentName, 'existingRefs': response});
+ });
+ });
+}
+
+function LookupReferencedUsers(emails, componentName) {
+ return new Promise((resolve, reject) => {
+ const userRefs = emails.map((displayName) => {
+ return {displayName};
+ });
+ const listReferencedUsers = prpcClient.call(
+ 'monorail.Users', 'ListReferencedUsers', {userRefs});
+ return listReferencedUsers.then((response) => {
+ resolve({'componentName': componentName, 'existingRefs': response});
+ });
+ });
+}
+
+// Extract referenced artifacts info functions.
+function ExtractCrbugProjectAndIssueIds(match, _currentProjectName) {
+ // When crbug links don't specify a project, the default project is Chromium.
+ const projectName = match[CRBUG_LINK_RE_PROJECT_GROUP] ||
+ CRBUG_DEFAULT_PROJECT;
+ const localId = match[CRBUG_LINK_RE_ID_GROUP];
+ return [{projectName: projectName, localId: localId}];
+}
+
+function ExtractTrackerProjectAndIssueIds(match, currentProjectName) {
+ const issueRefRE = PROJECT_LOCALID_RE;
+ let refMatch;
+ const refs = [];
+ while ((refMatch = issueRefRE.exec(match[0])) !== null) {
+ if (refMatch[PROJECT_LOCALID_RE_PROJECT_GROUP]) {
+ currentProjectName = refMatch[PROJECT_LOCALID_RE_PROJECT_GROUP];
+ }
+ refs.push({
+ projectName: currentProjectName,
+ localId: refMatch[PROJECT_LOCALID_RE_ID_GROUP],
+ });
+ }
+ return refs;
+}
+
+// Replace plain text references with links functions.
+function ReplaceIssueRef(stringMatch, projectName, localId, components,
+ commentId) {
+ if (components.openRefs && components.openRefs.length) {
+ const openRef = components.openRefs.find((ref) => {
+ return ref.localId && ref.projectName && (ref.localId == localId) &&
+ (ref.projectName.toLowerCase() === projectName.toLowerCase());
+ });
+ if (openRef) {
+ return createIssueRefRun(
+ projectName, localId, openRef.summary, false, stringMatch, commentId);
+ }
+ }
+ if (components.closedRefs && components.closedRefs.length) {
+ const closedRef = components.closedRefs.find((ref) => {
+ return ref.localId && ref.projectName && (ref.localId == localId) &&
+ (ref.projectName.toLowerCase() === projectName.toLowerCase());
+ });
+ if (closedRef) {
+ return createIssueRefRun(
+ projectName, localId, closedRef.summary, true, stringMatch,
+ commentId);
+ }
+ }
+ return {content: stringMatch};
+}
+
+function ReplaceCrbugIssueRef(match, components, _currentProjectName) {
+ components = components || {};
+ // When crbug links don't specify a project, the default project is Chromium.
+ const projectName =
+ match[CRBUG_LINK_RE_PROJECT_GROUP] || CRBUG_DEFAULT_PROJECT;
+ const localId = match[CRBUG_LINK_RE_ID_GROUP];
+ let commentId = '';
+ if (match[CRBUG_LINK_RE_COMMENT_GROUP] !== undefined) {
+ commentId = match[CRBUG_LINK_RE_COMMENT_GROUP];
+ }
+ return [ReplaceIssueRef(match[0], projectName, localId, components,
+ commentId)];
+}
+
+function ReplaceTrackerIssueRef(match, components, currentProjectName) {
+ components = components || {};
+ const issueRefRE = PROJECT_LOCALID_RE;
+ const commentId = '';
+ const textRuns = [];
+ let refMatch;
+ let pos = 0;
+ while ((refMatch = issueRefRE.exec(match[0])) !== null) {
+ if (refMatch.index > pos) {
+ // Create textrun for content between previous and current match.
+ textRuns.push({content: match[0].slice(pos, refMatch.index)});
+ }
+ if (refMatch[PROJECT_LOCALID_RE_PROJECT_GROUP]) {
+ currentProjectName = refMatch[PROJECT_LOCALID_RE_PROJECT_GROUP];
+ }
+ textRuns.push(ReplaceIssueRef(
+ refMatch[0], currentProjectName,
+ refMatch[PROJECT_LOCALID_RE_ID_GROUP], components, commentId));
+ pos = refMatch.index + refMatch[0].length;
+ }
+ if (match[0].slice(pos) !== '') {
+ textRuns.push({content: match[0].slice(pos)});
+ }
+ return textRuns;
+}
+
+function ReplaceUserRef(match, components, _currentProjectName) {
+ components = components || {};
+ const textRun = {content: match[0], tag: 'a'};
+ if (components.users && components.users.length) {
+ const existingUser = components.users.find((user) => {
+ return user.displayName.toLowerCase() === match[0].toLowerCase();
+ });
+ if (existingUser) {
+ textRun.href = `/u/${match[0]}`;
+ return [textRun];
+ }
+ }
+ textRun.href = `mailto:${match[0]}`;
+ return [textRun];
+}
+
+function ReplaceCommentBugRef(match) {
+ let textRun;
+ const issueNum = match[7];
+ const commentNum = match[18];
+ if (issueNum && commentNum) {
+ textRun = {content: match[0], tag: 'a', href: `?id=${issueNum}#c${commentNum}`};
+ } else if (commentNum) {
+ textRun = {content: match[0], tag: 'a', href: `#c${commentNum}`};
+ } else {
+ textRun = {content: match[0]};
+ }
+ return [textRun];
+}
+
+function ReplaceLinkRef(match, _components, _currentProjectName) {
+ const textRuns = [];
+ let content = match[0];
+ let trailing = '';
+ if (match[1]) {
+ textRuns.push({content: match[1]});
+ content = content.slice(match[1].length);
+ }
+ LINK_TRAILING_CHARS.forEach(([begin, end]) => {
+ if (content.endsWith(end)) {
+ if (!begin || !content.slice(0, -end.length).includes(begin)) {
+ trailing = end + trailing;
+ content = content.slice(0, -end.length);
+ }
+ }
+ });
+ let href = content;
+ const lowerHref = href.toLowerCase();
+ if (!lowerHref.startsWith('http') && !lowerHref.startsWith('ftp') &&
+ !lowerHref.startsWith('mailto')) {
+ // Prepend google-internal short links with http to
+ // prevent HTTPS error interstitial.
+ // SHORT_LINK_RE should not be used here as it might be
+ // in the middle of another match() process in an outer loop.
+ if (GOOG_SHORT_LINK_RE.test(lowerHref)) {
+ href = 'http://' + href;
+ } else {
+ href = 'https://' + href;
+ }
+ GOOG_SHORT_LINK_RE.lastIndex = 0;
+ }
+ textRuns.push({content: content, tag: 'a', href: href});
+ if (trailing.length) {
+ textRuns.push({content: trailing});
+ }
+ return textRuns;
+}
+
+function ReplaceRevisionRef(
+ match, _components, _currentProjectName, revisionUrlFormat) {
+ const content = match[0];
+ const href = revisionUrlFormat.replace('{revnum}', match[REV_NUM_GROUP]);
+ return [{content: content, tag: 'a', href: href}];
+}
+
+// Create custom textrun functions.
+function createIssueRefRun(projectName, localId, summary, isClosed, content,
+ commentId) {
+ return {
+ tag: 'a',
+ css: isClosed ? 'strike-through' : '',
+ href: `/p/${projectName}/issues/detail?id=${localId}${commentId}`,
+ title: summary || '',
+ content: content,
+ };
+}
+
+/**
+ * @typedef {Object} CommentReference
+ * @property {string} componentName A key identifying the kind of autolinking
+ * text the reference matches.
+ * @property {Array<any>} existingRefs Array of full data for referenced
+ * Objects. Each entry in this Array could be any kind of data depending
+ * on what the text references. For example, the Array could contain Issue
+ * or User Objects.
+ */
+
+/**
+ * Iterates through a list of comments, requests data for referenced objects
+ * in those comments, and returns all fetched data.
+ * @param {Array<IssueComment>} comments Array of comments to check.
+ * @param {string} currentProjectName Project these comments exist in the
+ * context of.
+ * @return {Promise<Array<CommentReference>>}
+ */
+function getReferencedArtifacts(comments, currentProjectName) {
+ return new Promise((resolve, reject) => {
+ const fetchPromises = [];
+ Components.forEach(({lookup, extractRefs, refRegs}, componentName) => {
+ if (lookup !== null) {
+ const refs = [];
+ refRegs.forEach((re) => {
+ let match;
+ comments.forEach((comment) => {
+ while ((match = re.exec(comment.content)) !== null) {
+ refs.push(...extractRefs(match, currentProjectName));
+ };
+ });
+ });
+ if (refs.length) {
+ fetchPromises.push(lookup(refs, componentName));
+ }
+ }
+ });
+ resolve(Promise.all(fetchPromises));
+ });
+}
+
+function markupAutolinks(
+ plainString, componentRefs, currentProjectName, revisionUrlFormat) {
+ plainString = plainString || '';
+ const chunks = plainString.trim().split(NEW_LINE_OR_BOLD_REGEX);
+ const textRuns = [];
+ chunks.filter(Boolean).forEach((chunk) => {
+ if (chunk.match(NEW_LINE_REGEX)) {
+ textRuns.push({tag: 'br'});
+ } else if (chunk.startsWith('<b>') && chunk.endsWith('</b>')) {
+ textRuns.push({content: chunk.slice(3, -4), tag: 'b'});
+ } else {
+ textRuns.push(
+ ...autolinkChunk(
+ chunk, componentRefs, currentProjectName, revisionUrlFormat));
+ }
+ });
+ return textRuns;
+}
+
+function autolinkChunk(
+ chunk, componentRefs, currentProjectName, revisionUrlFormat) {
+ let textRuns = [{content: chunk}];
+ Components.forEach(({refRegs, replacer}, componentName) => {
+ refRegs.forEach((re) => {
+ textRuns = applyLinks(
+ textRuns, replacer, re, componentRefs.get(componentName),
+ currentProjectName, revisionUrlFormat);
+ });
+ });
+ return textRuns;
+}
+
+function applyLinks(
+ textRuns, replacer, re, existingRefs, currentProjectName,
+ revisionUrlFormat) {
+ const resultRuns = [];
+ textRuns.forEach((textRun) => {
+ if (textRun.tag) {
+ resultRuns.push(textRun);
+ } else {
+ const content = textRun.content;
+ let pos = 0;
+ let match;
+ while ((match = re.exec(content)) !== null) {
+ if (match.index > pos) {
+ // Create textrun for content between previous and current match.
+ resultRuns.push({content: content.slice(pos, match.index)});
+ }
+ resultRuns.push(
+ ...replacer(
+ match, existingRefs, currentProjectName, revisionUrlFormat));
+ pos = match.index + match[0].length;
+ }
+ if (content.slice(pos) !== '') {
+ resultRuns.push({content: content.slice(pos)});
+ }
+ }
+ });
+ return resultRuns;
+}
+
+
+export const autolink = {Components, getReferencedArtifacts, markupAutolinks};