vscode/extensions/notebook-renderers/src/linkify.ts

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

212 lines
7.0 KiB
TypeScript
Raw Normal View History

2024-11-15 06:29:18 +00:00
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { ttPolicy } from './htmlHelper';
const CONTROL_CODES = '\\u0000-\\u0020\\u007f-\\u009f';
const WEB_LINK_REGEX = new RegExp('(?:[a-zA-Z][a-zA-Z0-9+.-]{2,}:\\/\\/|data:|www\\.)[^\\s' + CONTROL_CODES + '"]{2,}[^\\s' + CONTROL_CODES + '"\')}\\],:;.!?]', 'ug');
const WIN_ABSOLUTE_PATH = /(?<=^|\s)(?:[a-zA-Z]:(?:(?:\\|\/)[\w\.-]*)+)/;
const WIN_RELATIVE_PATH = /(?<=^|\s)(?:(?:\~|\.)(?:(?:\\|\/)[\w\.-]*)+)/;
const WIN_PATH = new RegExp(`(${WIN_ABSOLUTE_PATH.source}|${WIN_RELATIVE_PATH.source})`);
const POSIX_PATH = /(?<=^|\s)((?:\~|\.)?(?:\/[\w\.-]*)+)/;
const LINE_COLUMN = /(?:\:([\d]+))?(?:\:([\d]+))?/;
const isWindows = (typeof navigator !== 'undefined') ? navigator.userAgent && navigator.userAgent.indexOf('Windows') >= 0 : false;
const PATH_LINK_REGEX = new RegExp(`${isWindows ? WIN_PATH.source : POSIX_PATH.source}${LINE_COLUMN.source}`, 'g');
const HTML_LINK_REGEX = /<a\s+(?:[^>]*?\s+)?href=(["'])(.*?)\1[^>]*?>.*?<\/a>/gi;
const MAX_LENGTH = 2000;
type LinkKind = 'web' | 'path' | 'html' | 'text';
type LinkPart = {
kind: LinkKind;
value: string;
captures: string[];
};
export type LinkOptions = {
trustHtml?: boolean;
linkifyFilePaths: boolean;
};
export class LinkDetector {
// used by unit tests
static injectedHtmlCreator: (value: string) => string;
private shouldGenerateHtml(trustHtml: boolean) {
return trustHtml && (!!LinkDetector.injectedHtmlCreator || !!ttPolicy);
}
private createHtml(value: string) {
if (LinkDetector.injectedHtmlCreator) {
return LinkDetector.injectedHtmlCreator(value);
}
else {
return ttPolicy?.createHTML(value).toString();
}
}
/**
* Matches and handles web urls, absolute and relative file links in the string provided.
* Returns <span/> element that wraps the processed string, where matched links are replaced by <a/>.
* 'onclick' event is attached to all anchored links that opens them in the editor.
* When splitLines is true, each line of the text, even if it contains no links, is wrapped in a <span>
* and added as a child of the returned <span>.
*/
linkify(text: string, options: LinkOptions, splitLines?: boolean): HTMLElement {
if (splitLines) {
const lines = text.split('\n');
for (let i = 0; i < lines.length - 1; i++) {
lines[i] = lines[i] + '\n';
}
if (!lines[lines.length - 1]) {
// Remove the last element ('') that split added.
lines.pop();
}
const elements = lines.map(line => this.linkify(line, options, false));
if (elements.length === 1) {
// Do not wrap single line with extra span.
return elements[0];
}
const container = document.createElement('span');
elements.forEach(e => container.appendChild(e));
return container;
}
const container = document.createElement('span');
for (const part of this.detectLinks(text, !!options.trustHtml, options.linkifyFilePaths)) {
try {
let span: HTMLSpanElement | null = null;
switch (part.kind) {
case 'text':
container.appendChild(document.createTextNode(part.value));
break;
case 'web':
case 'path':
container.appendChild(this.createWebLink(part.value));
break;
case 'html':
span = document.createElement('span');
span.innerHTML = this.createHtml(part.value)!;
container.appendChild(span);
break;
}
} catch (e) {
container.appendChild(document.createTextNode(part.value));
}
}
return container;
}
private createWebLink(url: string): Node {
const link = this.createLink(url);
link.href = url;
return link;
}
// private createPathLink(text: string, path: string, lineNumber: number, columnNumber: number, workspaceFolder: string | undefined): Node {
// if (path[0] === '/' && path[1] === '/') {
// // Most likely a url part which did not match, for example ftp://path.
// return document.createTextNode(text);
// }
// const options = { selection: { startLineNumber: lineNumber, startColumn: columnNumber } };
// if (path[0] === '.') {
// if (!workspaceFolder) {
// return document.createTextNode(text);
// }
// const uri = workspaceFolder.toResource(path);
// const link = this.createLink(text);
// this.decorateLink(link, uri, (preserveFocus: boolean) => this.editorService.openEditor({ resource: uri, options: { ...options, preserveFocus } }));
// return link;
// }
// if (path[0] === '~') {
// const userHome = this.pathService.resolvedUserHome;
// if (userHome) {
// path = osPath.join(userHome.fsPath, path.substring(1));
// }
// }
// const link = this.createLink(text);
// link.tabIndex = 0;
// const uri = URI.file(osPath.normalize(path));
// this.fileService.resolve(uri).then(stat => {
// if (stat.isDirectory) {
// return;
// }
// this.decorateLink(link, uri, (preserveFocus: boolean) => this.editorService.openEditor({ resource: uri, options: { ...options, preserveFocus } }));
// }).catch(() => {
// // If the uri can not be resolved we should not spam the console with error, remain quite #86587
// });
// return link;
// }
private createLink(text: string): HTMLAnchorElement {
const link = document.createElement('a');
link.textContent = text;
return link;
}
private detectLinks(text: string, trustHtml: boolean, detectFilepaths: boolean): LinkPart[] {
if (text.length > MAX_LENGTH) {
return [{ kind: 'text', value: text, captures: [] }];
}
const regexes: RegExp[] = [];
const kinds: LinkKind[] = [];
const result: LinkPart[] = [];
if (this.shouldGenerateHtml(trustHtml)) {
regexes.push(HTML_LINK_REGEX);
kinds.push('html');
}
regexes.push(WEB_LINK_REGEX);
kinds.push('web');
if (detectFilepaths) {
regexes.push(PATH_LINK_REGEX);
kinds.push('path');
}
const splitOne = (text: string, regexIndex: number) => {
if (regexIndex >= regexes.length) {
result.push({ value: text, kind: 'text', captures: [] });
return;
}
const regex = regexes[regexIndex];
let currentIndex = 0;
let match;
regex.lastIndex = 0;
while ((match = regex.exec(text)) !== null) {
const stringBeforeMatch = text.substring(currentIndex, match.index);
if (stringBeforeMatch) {
splitOne(stringBeforeMatch, regexIndex + 1);
}
const value = match[0];
result.push({
value: value,
kind: kinds[regexIndex],
captures: match.slice(1)
});
currentIndex = match.index + value.length;
}
const stringAfterMatches = text.substring(currentIndex);
if (stringAfterMatches) {
splitOne(stringAfterMatches, regexIndex + 1);
}
};
splitOne(text, 0);
return result;
}
}
const linkDetector = new LinkDetector();
export function linkify(text: string, linkOptions: LinkOptions, splitLines?: boolean) {
return linkDetector.linkify(text, linkOptions, splitLines);
}