chromium-dashboard/client-src/elements/autolink.ts

230 строки
7.0 KiB
TypeScript

// This is an implementation of autolinking pulled from Monorail and repurposed
// for use with text entries in WebStatus. Use this directly via './utils.js'
// See: https://chromium.googlesource.com/infra/infra/+/refs/heads/main/appengine/monorail/static_src/autolink.js
import {FeatureLink} from '../js-src/cs-client';
import {enhanceAutolink} from './chromedash-link.js';
interface TextRun {
content: string;
tag?: string;
href?: string;
}
interface Component {
refRegs: RegExp[];
replacer: (match: any) => never[] | TextRun[];
}
const CRBUG_DEFAULT_PROJECT = 'chromium';
const CRBUG_URL = 'https://bugs.chromium.org';
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 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 LINK_TRAILING_CHARS = [
[null, ':'],
[null, '.'],
[null, ','],
[null, '>'],
['(', ')'],
['[', ']'],
['{', '}'],
["'", "'"],
['"', '"'],
] as const;
const GOOG_SHORT_LINK_RE =
/^(b|t|o|omg|cl|cr|go|g|shortn|who|teams|fxr|fxrev|fxb|tqr)\/.*/gi;
const Components = new Map<string, Component>();
Components.set('00-commentbug', {
refRegs: [PROJECT_COMMENT_BUG_RE],
replacer: replaceCommentBugRef,
});
Components.set('02-full-urls', {
refRegs: [IS_LINK_RE],
replacer: replaceLinkRef,
});
// 03-user-emails unused.
Components.set('04-tracker-regular', {
refRegs: [ISSUE_TRACKER_RE],
replacer: replaceTrackerIssueRef,
});
Components.set('05-linkify-shorthand', {
refRegs: [SHORT_LINK_RE, NUMERIC_SHORT_LINK_RE, IMPLIED_LINK_RE],
replacer: replaceLinkRef,
});
// 06-versioncontrol unused.
// Replace plain text references with links functions.
function replaceIssueRef(
stringMatch: string,
projectName: string,
localId: string,
commentId: string
) {
return createIssueRefRun(projectName, localId, stringMatch, commentId);
}
function replaceTrackerIssueRef(
match: RegExpExecArray,
currentProjectName = CRBUG_DEFAULT_PROJECT
) {
const issueRefRE = PROJECT_LOCALID_RE;
const commentId = '';
const textRuns: TextRun[] = [];
let refMatch: RegExpExecArray | null;
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],
commentId
)
);
pos = refMatch.index + refMatch[0].length;
}
if (match[0].slice(pos) !== '') {
textRuns.push({content: match[0].slice(pos)});
}
return textRuns;
}
function replaceCommentBugRef(match: RegExpExecArray) {
let textRun: TextRun;
const issueNum = match[7];
const commentNum = match[18];
if (issueNum && commentNum) {
const href = `${CRBUG_URL}/p/${CRBUG_DEFAULT_PROJECT}/issues/detail?id=${issueNum}#c${commentNum}`;
textRun = {content: match[0], tag: 'a', href};
} else if (commentNum) {
const href = `${CRBUG_URL}/p/${CRBUG_DEFAULT_PROJECT}/issues/detail#c${commentNum}`;
textRun = {content: match[0], tag: 'a', href};
} else {
textRun = {content: match[0]};
}
return [textRun];
}
function replaceLinkRef(match: RegExpExecArray) {
const textRuns: TextRun[] = [];
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;
}
// Create custom textrun functions.
function createIssueRefRun(projectName, localId, content, commentId) {
return {
tag: 'a',
content: content,
href: `${CRBUG_URL}/p/${projectName}/issues/detail?id=${localId}${commentId}`,
};
}
export function markupAutolinks(plainString, featureLinks: FeatureLink[] = []) {
plainString = plainString || '';
const chunks = [plainString.trim()];
const textRuns: TextRun[] = [];
chunks.filter(Boolean).forEach(chunk => {
textRuns.push(...autolinkChunk(chunk));
});
const result = textRuns.map(part => {
if (part.tag === 'a') {
// if the link is a feature link, enhance it to provide more information
return enhanceAutolink(part, featureLinks);
}
return part.content;
});
return result;
}
function autolinkChunk(chunk) {
let textRuns = [{content: chunk}];
Components.forEach(({refRegs, replacer}) => {
refRegs.forEach(re => {
textRuns = applyLinks(textRuns, replacer, re);
});
});
return textRuns;
}
function applyLinks(textRuns, replacer, re) {
const resultRuns: TextRun[] = [];
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));
pos = match.index + match[0].length;
}
if (content.slice(pos) !== '') {
resultRuns.push({content: content.slice(pos)});
}
}
});
return resultRuns;
}