blob: afc67453523dc210bc18e5bb60d5dc558fc56b3f [file] [log] [blame]
/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {CommentLinkInfo, CommentLinks} from '../types/common';
import {getBaseUrl} from './url-util';
/**
* Finds links within the base string and convert them to HTML. Config-based
* rewrites are only applied on text that is not linked by the default linking
* library.
*/
export function linkifyUrlsAndApplyRewrite(
base: string,
repoCommentLinks: CommentLinks
): string {
const rewriteResults = getRewriteResultsFromConfig(base, repoCommentLinks);
return applyRewrites(base, rewriteResults);
}
/**
* Generates a list of rewrites that would be applied to a base string. They are
* not applied immediately to the base text because one rewrite may interfere or
* overlap with a later rewrite. Only after all rewrites are known they are
* carefully merged with `applyRewrites`.
*/
function getRewriteResultsFromConfig(
base: string,
repoCommentLinks: CommentLinks
): RewriteResult[] {
const enabledRewrites = Object.values(repoCommentLinks).filter(
commentLinkInfo =>
commentLinkInfo.enabled !== false &&
(commentLinkInfo.link !== undefined || commentLinkInfo.html !== undefined)
);
// Always linkify URLs starting with https?://
enabledRewrites.push({
match: '(https?://\\S+[\\w/])',
link: '$1',
});
return enabledRewrites.flatMap(rewrite => {
const regexp = new RegExp(rewrite.match, 'g');
const partialResults: RewriteResult[] = [];
let match: RegExpExecArray | null;
while ((match = regexp.exec(base)) !== null) {
const fullReplacementText = getReplacementText(match[0], rewrite);
// The replacement may not be changing the entire matched substring so we
// "trim" the replacement position and text to the part that is actually
// different. This makes sure that unchanged portions are still eligible
// for other rewrites without being rejected as overlaps during
// `applyRewrites`. The new `replacementText` is not eligible for other
// rewrites since it would introduce unexpected interactions between
// rewrites depending on their order of definition/execution.
const sharedPrefixLength = getSharedPrefixLength(
match[0],
fullReplacementText
);
const sharedSuffixLength = getSharedSuffixLength(
match[0],
fullReplacementText
);
const prefixIndex = sharedPrefixLength;
const matchSuffixIndex = match[0].length - sharedSuffixLength;
const fullReplacementSuffixIndex =
fullReplacementText.length - sharedSuffixLength;
partialResults.push({
replacedTextStartPosition: match.index + prefixIndex,
replacedTextEndPosition: match.index + matchSuffixIndex,
replacementText: fullReplacementText.substring(
prefixIndex,
fullReplacementSuffixIndex
),
});
}
return partialResults;
});
}
/**
* Applies all the rewrites to the given base string. To resolve cases where
* multiple rewrites target overlapping pieces of the base string, the rewrite
* that ends latest is kept and the rest are not applied and discarded.
*/
function applyRewrites(base: string, rewriteResults: RewriteResult[]): string {
const rewritesByEndPosition = [...rewriteResults].sort((a, b) => {
if (b.replacedTextEndPosition !== a.replacedTextEndPosition) {
return b.replacedTextEndPosition - a.replacedTextEndPosition;
}
return a.replacedTextStartPosition - b.replacedTextStartPosition;
});
const filteredSortedRewrites: RewriteResult[] = [];
let latestReplace = base.length;
for (const rewrite of rewritesByEndPosition) {
// Only accept rewrites that do not overlap with any previously accepted
// rewrites.
if (rewrite.replacedTextEndPosition <= latestReplace) {
filteredSortedRewrites.push(rewrite);
latestReplace = rewrite.replacedTextStartPosition;
}
}
return filteredSortedRewrites.reduce(
(text, rewrite) =>
text
.substring(0, rewrite.replacedTextStartPosition)
.concat(rewrite.replacementText)
.concat(text.substring(rewrite.replacedTextEndPosition)),
base
);
}
/**
* For a given regexp match, apply the rewrite based on the rewrite's type and
* return the resulting string.
*/
function getReplacementText(
matchedText: string,
rewrite: CommentLinkInfo
): string {
if (rewrite.link !== undefined) {
const replacementHref = rewrite.link.startsWith('/')
? `${getBaseUrl()}${rewrite.link}`
: rewrite.link;
const regexp = new RegExp(rewrite.match, 'g');
return matchedText.replace(
regexp,
createLinkTemplate(
replacementHref,
rewrite.text ?? '$&',
rewrite.prefix,
rewrite.suffix
)
);
} else if (rewrite.html !== undefined) {
return matchedText.replace(new RegExp(rewrite.match, 'g'), rewrite.html);
} else {
throw new Error('commentLinkInfo is not a link or html rewrite');
}
}
function createLinkTemplate(
href: string,
displayText: string,
prefix?: string,
suffix?: string
) {
return `${
prefix ?? ''
}<a href="${href}" rel="noopener" target="_blank">${displayText}</a>${
suffix ?? ''
}`;
}
/**
* Returns the number of characters that are identical at the start of both
* strings.
*
* For example, `getSharedPrefixLength('12345678', '1234zz78')` would return 4
*/
function getSharedPrefixLength(a: string, b: string) {
let i = 0;
for (; i < a.length && i < b.length; ++i) {
if (a[i] !== b[i]) {
return i;
}
}
return i;
}
/**
* Returns the number of characters that are identical at the end of both
* strings.
*
* For example, `getSharedSuffixLength('12345678', '1234zz78')` would return 2
*/
function getSharedSuffixLength(a: string, b: string) {
let i = a.length;
for (let j = b.length; i !== 0 && j !== 0; --i, --j) {
if (a[i] !== b[j]) {
return a.length - 1 - i;
}
}
return a.length - i;
}
interface RewriteResult {
replacedTextStartPosition: number;
replacedTextEndPosition: number;
replacementText: string;
}