@jupyterlab/rendermime
Version:
JupyterLab - RenderMime
1,714 lines (1,550 loc) • 46.8 kB
text/typescript
/* -----------------------------------------------------------------------------
| Copyright (c) Jupyter Development Team.
| Distributed under the terms of the Modified BSD License.
|----------------------------------------------------------------------------*/
import { URLExt } from '@jupyterlab/coreutils';
import { IRenderMime } from '@jupyterlab/rendermime-interfaces';
import { ITranslator, nullTranslator } from '@jupyterlab/translation';
import escape from 'lodash.escape';
import { removeMath, replaceMath } from './latex';
/**
* Render HTML into a host node.
*
* @param options - The options for rendering.
*
* @returns A promise which resolves when rendering is complete.
*/
export function renderHTML(options: renderHTML.IOptions): Promise<void> {
// Unpack the options.
let {
host,
source,
trusted,
sanitizer,
resolver,
linkHandler,
shouldTypeset,
latexTypesetter,
translator
} = options;
translator = translator || nullTranslator;
const trans = translator?.load('jupyterlab');
let originalSource = source;
// Bail early if the source is empty.
if (!source) {
host.textContent = '';
return Promise.resolve(undefined);
}
// Sanitize the source if it is not trusted. This removes all
// `<script>` tags as well as other potentially harmful HTML.
if (!trusted) {
originalSource = `${source}`;
source = sanitizer.sanitize(source);
}
// Set the inner HTML of the host.
host.innerHTML = source;
if (host.getElementsByTagName('script').length > 0) {
// If output it trusted, eval any script tags contained in the HTML.
// This is not done automatically by the browser when script tags are
// created by setting `innerHTML`.
if (trusted) {
Private.evalInnerHTMLScriptTags(host);
} else {
const container = document.createElement('div');
const warning = document.createElement('pre');
warning.textContent = trans.__(
'This HTML output contains inline scripts. Are you sure that you want to run arbitrary Javascript within your JupyterLab session?'
);
const runButton = document.createElement('button');
runButton.textContent = trans.__('Run');
runButton.onclick = event => {
host.innerHTML = originalSource;
Private.evalInnerHTMLScriptTags(host);
if (host.firstChild) {
host.removeChild(host.firstChild);
}
};
container.appendChild(warning);
container.appendChild(runButton);
host.insertBefore(container, host.firstChild);
}
}
// Handle default behavior of nodes.
Private.handleDefaults(host, resolver);
// Patch the urls if a resolver is available.
let promise: Promise<void>;
if (resolver) {
promise = Private.handleUrls(host, resolver, linkHandler);
} else {
promise = Promise.resolve(undefined);
}
// Return the final rendered promise.
return promise.then(() => {
if (shouldTypeset && latexTypesetter) {
latexTypesetter.typeset(host);
}
});
}
/**
* The namespace for the `renderHTML` function statics.
*/
export namespace renderHTML {
/**
* The options for the `renderHTML` function.
*/
export interface IOptions {
/**
* The host node for the rendered HTML.
*/
host: HTMLElement;
/**
* The HTML source to render.
*/
source: string;
/**
* Whether the source is trusted.
*/
trusted: boolean;
/**
* The html sanitizer for untrusted source.
*/
sanitizer: IRenderMime.ISanitizer;
/**
* An optional url resolver.
*/
resolver: IRenderMime.IResolver | null;
/**
* An optional link handler.
*/
linkHandler: IRenderMime.ILinkHandler | null;
/**
* Whether the node should be typeset.
*/
shouldTypeset: boolean;
/**
* The LaTeX typesetter for the application.
*/
latexTypesetter: IRenderMime.ILatexTypesetter | null;
/**
* The application language translator.
*/
translator?: ITranslator;
}
}
/**
* Render an image into a host node.
*
* @param options - The options for rendering.
*
* @returns A promise which resolves when rendering is complete.
*/
export function renderImage(
options: renderImage.IRenderOptions
): Promise<void> {
// Unpack the options.
const { host, mimeType, source, width, height, needsBackground, unconfined } =
options;
// Clear the content in the host.
host.textContent = '';
// Create the image element.
const img = document.createElement('img');
// Set the source of the image.
img.src = `data:${mimeType};base64,${source}`;
// Set the size of the image if provided.
if (typeof height === 'number') {
img.height = height;
}
if (typeof width === 'number') {
img.width = width;
}
if (needsBackground === 'light') {
img.classList.add('jp-needs-light-background');
} else if (needsBackground === 'dark') {
img.classList.add('jp-needs-dark-background');
}
if (unconfined === true) {
img.classList.add('jp-mod-unconfined');
}
// Add the image to the host.
host.appendChild(img);
// Return the rendered promise.
return Promise.resolve(undefined);
}
/**
* The namespace for the `renderImage` function statics.
*/
export namespace renderImage {
/**
* The options for the `renderImage` function.
*/
export interface IRenderOptions {
/**
* The image node to update with the content.
*/
host: HTMLElement;
/**
* The mime type for the image.
*/
mimeType: string;
/**
* The base64 encoded source for the image.
*/
source: string;
/**
* The optional width for the image.
*/
width?: number;
/**
* The optional height for the image.
*/
height?: number;
/**
* Whether an image requires a background for legibility.
*/
needsBackground?: string;
/**
* Whether the image should be unconfined.
*/
unconfined?: boolean;
}
}
/**
* Render LaTeX into a host node.
*
* @param options - The options for rendering.
*
* @returns A promise which resolves when rendering is complete.
*/
export function renderLatex(
options: renderLatex.IRenderOptions
): Promise<void> {
// Unpack the options.
const { host, source, shouldTypeset, latexTypesetter } = options;
// Set the source on the node.
host.textContent = source;
// Typeset the node if needed.
if (shouldTypeset && latexTypesetter) {
latexTypesetter.typeset(host);
}
// Return the rendered promise.
return Promise.resolve(undefined);
}
/**
* The namespace for the `renderLatex` function statics.
*/
export namespace renderLatex {
/**
* The options for the `renderLatex` function.
*/
export interface IRenderOptions {
/**
* The host node for the rendered LaTeX.
*/
host: HTMLElement;
/**
* The LaTeX source to render.
*/
source: string;
/**
* Whether the node should be typeset.
*/
shouldTypeset: boolean;
/**
* The LaTeX typesetter for the application.
*/
latexTypesetter: IRenderMime.ILatexTypesetter | null;
}
}
/**
* Render Markdown into a host node.
*
* @param options - The options for rendering.
*
* @returns A promise which resolves when rendering is complete.
*/
export async function renderMarkdown(
options: renderMarkdown.IRenderOptions
): Promise<void> {
// Unpack the options.
const { host, source, markdownParser, ...others } = options;
// Clear the content if there is no source.
if (!source) {
host.textContent = '';
return;
}
let html = '';
if (markdownParser) {
// Separate math from normal markdown text.
const parts = removeMath(source);
// Convert the markdown to HTML.
html = await markdownParser.render(parts['text']);
// Replace math.
html = replaceMath(html, parts['math']);
} else {
// Fallback if the application does not have any markdown parser.
html = `<pre>${source}</pre>`;
}
// Render HTML.
await renderHTML({
host,
source: html,
...others
});
// Apply ids to the header nodes.
Private.headerAnchors(host);
}
/**
* The namespace for the `renderMarkdown` function statics.
*/
export namespace renderMarkdown {
/**
* The options for the `renderMarkdown` function.
*/
export interface IRenderOptions {
/**
* The host node for the rendered Markdown.
*/
host: HTMLElement;
/**
* The Markdown source to render.
*/
source: string;
/**
* Whether the source is trusted.
*/
trusted: boolean;
/**
* The html sanitizer for untrusted source.
*/
sanitizer: IRenderMime.ISanitizer;
/**
* An optional url resolver.
*/
resolver: IRenderMime.IResolver | null;
/**
* An optional link handler.
*/
linkHandler: IRenderMime.ILinkHandler | null;
/**
* Whether the node should be typeset.
*/
shouldTypeset: boolean;
/**
* The LaTeX typesetter for the application.
*/
latexTypesetter: IRenderMime.ILatexTypesetter | null;
/**
* The Markdown parser.
*/
markdownParser: IRenderMime.IMarkdownParser | null;
/**
* The application language translator.
*/
translator?: ITranslator;
}
/**
* Create a normalized id for a header element.
*
* @param header Header element
* @returns Normalized id
*/
export function createHeaderId(header: Element): string {
return (header.textContent ?? '').replace(/ /g, '-');
}
}
/**
* Render SVG into a host node.
*
* @param options - The options for rendering.
*
* @returns A promise which resolves when rendering is complete.
*/
export function renderSVG(options: renderSVG.IRenderOptions): Promise<void> {
// Unpack the options.
let { host, source, trusted, unconfined } = options;
// Clear the content if there is no source.
if (!source) {
host.textContent = '';
return Promise.resolve(undefined);
}
// Display a message if the source is not trusted.
if (!trusted) {
host.textContent =
'Cannot display an untrusted SVG. Maybe you need to run the cell?';
return Promise.resolve(undefined);
}
// Add missing SVG namespace (if actually missing)
const patt = '<svg[^>]+xmlns=[^>]+svg';
if (source.search(patt) < 0) {
source = source.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"');
}
// Render in img so that user can save it easily
const img = new Image();
img.src = `data:image/svg+xml,${encodeURIComponent(source)}`;
host.appendChild(img);
if (unconfined === true) {
host.classList.add('jp-mod-unconfined');
}
return Promise.resolve();
}
/**
* The namespace for the `renderSVG` function statics.
*/
export namespace renderSVG {
/**
* The options for the `renderSVG` function.
*/
export interface IRenderOptions {
/**
* The host node for the rendered SVG.
*/
host: HTMLElement;
/**
* The SVG source.
*/
source: string;
/**
* Whether the source is trusted.
*/
trusted: boolean;
/**
* Whether the svg should be unconfined.
*/
unconfined?: boolean;
/**
* The application language translator.
*/
translator: ITranslator;
}
}
/**
* Options for auto linker.
*/
interface IAutoLinkOptions {
/**
* Whether to look for web URLs e.g. indicated by http schema or www prefix.
*/
checkWeb: boolean;
/**
* Whether to look for path URIs.
*/
checkPaths: boolean;
}
interface ILinker {
/**
* Regular expression capturing links in the group named `path`.
*
* Full match extend will be used as label for the link.
* Additional named groups represent locator fragments.
*/
regex: RegExp;
/**
* Create the anchor element.
*/
createAnchor: (
text: string,
label: string,
attributes?: Record<string, string>
) => HTMLAnchorElement;
/**
* Modify the path value if needed.
*/
processPath?: (text: string) => string;
/**
* Modify the label if needed.
*/
processLabel?: (text: string) => string;
}
namespace ILinker {
// Taken from Visual Studio Code:
// https://github.com/microsoft/vscode/blob/9f709d170b06e991502153f281ec3c012add2e42/src/vs/workbench/contrib/debug/browser/linkDetector.ts#L17-L18
const controlCodes = '\\u0000-\\u0020\\u007f-\\u009f';
export const webLinkRegex = new RegExp(
'(?<path>(?:[a-zA-Z][a-zA-Z0-9+.-]{2,}:\\/\\/|data:|www\\.)[^\\s' +
controlCodes +
'"]{2,}[^\\s' +
controlCodes +
'"\'(){}\\[\\],:;.!?])',
'ug'
);
// Taken from Visual Studio Code:
// https://github.com/microsoft/vscode/blob/3e407526a1e2ff22cacb69c7e353e81a12f41029/extensions/notebook-renderers/src/linkify.ts#L9
const winAbsPathRegex = /(?:[a-zA-Z]:(?:(?:\\|\/)[\w\.-]*)+)/;
const winRelPathRegex = /(?:(?:\~|\.)(?:(?:\\|\/)[\w\.-]*)+)/;
const winPathRegex = new RegExp(
`(${winAbsPathRegex.source}|${winRelPathRegex.source})`
);
const posixPathRegex = /((?:\~|\.)?(?:\/[\w\.-]*)+)/;
const lineColumnRegex =
/(?:(?:\:|", line )(?<line>[\d]+))?(?:\:(?<column>[\d]+))?/;
// TODO: this ought to come from kernel (browser may be on a different OS).
const isWindows = navigator.userAgent.indexOf('Windows') >= 0;
export const pathLinkRegex = new RegExp(
`(?<path>${isWindows ? winPathRegex.source : posixPathRegex.source})${
lineColumnRegex.source
}`,
'g'
);
}
/**
* Linker for web URLs.
*/
class WebLinker implements ILinker {
regex = ILinker.webLinkRegex;
createAnchor(url: string, label: string) {
const anchor = document.createElement('a');
anchor.href = url.startsWith('www.') ? 'https://' + url : url;
anchor.rel = 'noopener';
anchor.target = '_blank';
anchor.appendChild(document.createTextNode(label));
return anchor;
}
processPath(url: string) {
// Special case when the URL ends with ">" or "<"
const lastChars = url.slice(-1);
const endsWithGtLt = ['>', '<'].indexOf(lastChars) !== -1;
const len = endsWithGtLt ? url.length - 1 : url.length;
url = url.slice(0, len);
return url;
}
processLabel(url: string) {
return this.processPath(url);
}
}
/**
* Linker for path URIs.
*/
class PathLinker implements ILinker {
regex = ILinker.pathLinkRegex;
createAnchor(path: string, label: string, locators: Record<string, string>) {
const anchor = document.createElement('a');
// Store the path in dataset.
// Do not set `href` - at this point we do not know if the path is valid and
// accessible for application (and we want rendering those as links).
anchor.dataset.path = path;
// Store line using RFC 5147 fragment locator for text/plain files.
// It could be expanded to other formats, e.g. based on file extension.
const line = parseInt(locators['line'], 10);
let locator: string = !isNaN(line) ? `line=${line - 1}` : '';
anchor.dataset.locator = locator;
anchor.appendChild(document.createTextNode(label));
return anchor;
}
}
function autolink(
content: string,
options: IAutoLinkOptions
): Array<HTMLAnchorElement | Text> {
const linkers: ILinker[] = [];
if (options.checkWeb) {
linkers.push(new WebLinker());
}
if (options.checkPaths) {
linkers.push(new PathLinker());
}
const nodes: Array<HTMLAnchorElement | Text> = [];
// There are two ways to implement competitive regexes:
// - two heads (which would need to resolve overlaps), or
// - (simpler) divide and recurse (implemented below)
const linkify = (content: string, regexIndex: number) => {
if (regexIndex >= linkers.length) {
nodes.push(document.createTextNode(content));
return;
}
const linker = linkers[regexIndex];
let match: RegExpExecArray | null;
let currentIndex = 0;
const regex = linker.regex;
// Reset regex
regex.lastIndex = 0;
while (null != (match = regex.exec(content))) {
const stringBeforeMatch = content.substring(currentIndex, match.index);
if (stringBeforeMatch) {
linkify(stringBeforeMatch, regexIndex + 1);
}
const { path, ...locators } = match.groups!;
const value = linker.processPath ? linker.processPath(path) : path;
const label = linker.processLabel
? linker.processLabel(match[0])
: match[0];
nodes.push(linker.createAnchor(value, label, locators));
currentIndex = match.index + label.length;
}
const stringAfterMatches = content.substring(currentIndex);
if (stringAfterMatches) {
linkify(stringAfterMatches, regexIndex + 1);
}
};
linkify(content, 0);
return nodes;
}
/**
* Split a shallow node (node without nested nodes inside) at a given text content position.
*
* @param node the shallow node to be split
* @param at the position in textContent at which the split should occur
*/
function splitShallowNode<T extends Node>(
node: T,
at: number
): { pre: T; post: T } {
const pre = node.cloneNode() as T;
pre.textContent = node.textContent?.slice(0, at) as string;
const post = node.cloneNode() as T;
post.textContent = node.textContent?.slice(at) as string;
return {
pre,
post
};
}
/**
* Iterate over some nodes, while tracking cumulative start and end position.
*/
function* nodeIter<T extends Node>(
nodes: T[]
): IterableIterator<{ node: T; start: number; end: number; isText: boolean }> {
let start = 0;
let end;
for (let node of nodes) {
end = start + (node.textContent?.length || 0);
yield {
node,
start,
end,
isText: node.nodeType === Node.TEXT_NODE
};
start = end;
}
}
/**
* Align two collections of nodes.
*
* If a text node in one collections spans an element in the other, yield the spanned elements.
* Otherwise, split the nodes such that yielded pair start and stop on the same position.
*/
function* alignedNodes<T extends Node, U extends Node>(
a: T[],
b: U[]
): IterableIterator<[T, null] | [null, U] | [T, U]> {
let iterA = nodeIter(a);
let iterB = nodeIter(b);
let nA = iterA.next();
let nB = iterB.next();
while (!nA.done && !nB.done) {
let A = nA.value;
let B = nB.value;
if (A.isText && A.start <= B.start && A.end >= B.end) {
// A is a text element that spans all of B, simply yield B
yield [null, B.node];
nB = iterB.next();
} else if (B.isText && B.start <= A.start && B.end >= A.end) {
// B is a text element that spans all of A, simply yield A
yield [A.node, null];
nA = iterA.next();
} else {
// There is some intersection, split one, unless they match exactly
if (A.end === B.end && A.start === B.start) {
yield [A.node, B.node];
nA = iterA.next();
nB = iterB.next();
} else if (A.end > B.end) {
/*
A |-----[======]---|
B |--[======]------|
| <- Split A here
| <- trim B to start from here if needed
*/
let { pre, post } = splitShallowNode(A.node, B.end - A.start);
if (B.start < A.start) {
// this node should not be yielded anywhere else, so ok to modify in-place
B.node.textContent = B.node.textContent?.slice(
A.start - B.start
) as string;
}
yield [pre, B.node];
// Modify iteration result in-place:
A.node = post;
A.start = B.end;
nB = iterB.next();
} else if (B.end > A.end) {
let { pre, post } = splitShallowNode(B.node, A.end - B.start);
if (A.start < B.start) {
// this node should not be yielded anywhere else, so ok to modify in-place
A.node.textContent = A.node.textContent?.slice(
B.start - A.start
) as string;
}
yield [A.node, pre];
// Modify iteration result in-place:
B.node = post;
B.start = A.end;
nA = iterA.next();
} else {
throw new Error(
`Unexpected intersection: ${JSON.stringify(A)} ${JSON.stringify(B)}`
);
}
}
}
}
/**
* Render text into a host node.
*
* @param options - The options for rendering.
*
* @returns A promise which resolves when rendering is complete.
*/
export function renderText(options: renderText.IRenderOptions): Promise<void> {
renderTextual(options, {
checkWeb: true,
checkPaths: false
});
// Return the rendered promise.
return Promise.resolve(undefined);
}
/**
* Sanitize HTML out using native browser sanitizer.
*
* Compared to the `ISanitizer.sanitize` this does not allow to selectively
* allow to keep certain tags but escapes everything; on the other hand
* it is much faster as it uses platform-optimized code.
*/
function nativeSanitize(source: string): string {
const el = document.createElement('span');
el.textContent = source;
return el.innerHTML;
}
/**
* Render the textual representation into a host node.
*
* Implements the shared logic for `renderText` and `renderError`.
*/
function renderTextual(
options: renderText.IRenderOptions,
autoLinkOptions: IAutoLinkOptions
): void {
// Unpack the options.
const { host, sanitizer, source } = options;
const ansiPrefixRe = /\x1b/; // eslint-disable-line no-control-regex
const hasAnsiPrefix = ansiPrefixRe.test(source);
// Create the HTML content:
// If no ANSI codes are present use a fast path for escaping.
const content = hasAnsiPrefix
? sanitizer.sanitize(Private.ansiSpan(source), {
allowedTags: ['span']
})
: nativeSanitize(source);
// Set the sanitized content for the host node.
const pre = document.createElement('pre');
pre.innerHTML = content;
const preTextContent = pre.textContent;
const cacheStoreOptions = [];
if (autoLinkOptions.checkWeb) {
cacheStoreOptions.push('web');
}
if (autoLinkOptions.checkPaths) {
cacheStoreOptions.push('paths');
}
const cacheStoreKey = cacheStoreOptions.join('-');
let cacheStore = Private.autoLinkCache.get(cacheStoreKey);
if (!cacheStore) {
cacheStore = new WeakMap();
Private.autoLinkCache.set(cacheStoreKey, cacheStore);
}
let ret: HTMLPreElement;
if (preTextContent) {
// Note: only text nodes and span elements should be present after sanitization in the `<pre>` element.
let linkedNodes: (HTMLAnchorElement | Text)[];
if (sanitizer.getAutolink?.() ?? true) {
const cache = getApplicableLinkCache(
cacheStore.get(host),
preTextContent
);
if (cache) {
const { cachedNodes: fromCache, addedText } = cache;
const newAdditions = autolink(addedText, autoLinkOptions);
const lastInCache = fromCache[fromCache.length - 1];
const firstNewNode = newAdditions[0];
if (lastInCache instanceof Text && firstNewNode instanceof Text) {
const joiningNode = lastInCache;
joiningNode.data += firstNewNode.data;
linkedNodes = [
...fromCache.slice(0, -1),
joiningNode,
...newAdditions.slice(1)
];
} else {
linkedNodes = [...fromCache, ...newAdditions];
}
} else {
linkedNodes = autolink(preTextContent, autoLinkOptions);
}
cacheStore.set(host, {
preTextContent,
// Clone the nodes before storing them in the cache in case if another component
// attempts to modify (e.g. dispose of) them - which is the case for search highlights!
linkedNodes: linkedNodes.map(
node => node.cloneNode(true) as HTMLAnchorElement | Text
)
});
} else {
linkedNodes = [document.createTextNode(content)];
}
const preNodes = Array.from(pre.childNodes) as (Text | HTMLSpanElement)[];
ret = mergeNodes(preNodes, linkedNodes);
} else {
ret = document.createElement('pre');
}
host.appendChild(ret);
}
/**
* The namespace for the `renderText` function statics.
*/
export namespace renderText {
/**
* The options for the `renderText` function.
*/
export interface IRenderOptions {
/**
* The host node for the text content.
*/
host: HTMLElement;
/**
* The html sanitizer for untrusted source.
*/
sanitizer: IRenderMime.ISanitizer;
/**
* The source text to render.
*/
source: string;
/**
* The application language translator.
*/
translator?: ITranslator;
}
}
interface IAutoLinkCacheEntry {
preTextContent: string;
linkedNodes: (HTMLAnchorElement | Text)[];
}
/**
* Return the information from the cache that can be used given the cache entry and current text.
* If the cache is invalid given the current text (or cannot be used) `null` is returned.
*/
function getApplicableLinkCache(
cachedResult: IAutoLinkCacheEntry | undefined,
preTextContent: string
): {
cachedNodes: IAutoLinkCacheEntry['linkedNodes'];
addedText: string;
} | null {
if (!cachedResult) {
return null;
}
if (preTextContent.length < cachedResult.preTextContent.length) {
// If the new content is shorter than the cached content
// we cannot use the cache as we only support appending.
return null;
}
let addedText = preTextContent.substring(cachedResult.preTextContent.length);
let cachedNodes = cachedResult.linkedNodes;
const lastCachedNode =
cachedResult.linkedNodes[cachedResult.linkedNodes.length - 1];
// Only use cached nodes if:
// - the last cached node is a text node
// - the new content starts with a new line
// - the old content ends with a new line
if (
cachedResult.preTextContent.endsWith('\n') ||
addedText.startsWith('\n')
) {
// Second or third condition is met, we can use the cached nodes
// (this is a no-op, we just continue execution).
} else if (lastCachedNode instanceof Text) {
// The first condition is met, we can use the cached nodes,
// but first we remove the Text node to re-analyse its text.
// This is required when we cached `aaa www.one.com bbb www.`
// and the incoming addition is `two.com`. We can still
// use text node `aaa ` and anchor node `www.one.com`, but
// we need to pass `bbb www.` + `two.com` through linkify again.
cachedNodes = cachedNodes.slice(0, -1);
addedText = lastCachedNode.textContent + addedText;
} else {
return null;
}
// Finally check if text has not changed.
if (!preTextContent.startsWith(cachedResult.preTextContent)) {
return null;
}
return {
cachedNodes,
addedText
};
}
/**
* Render error into a host node.
*
* @param options - The options for rendering.
*
* @returns A promise which resolves when rendering is complete.
*/
export function renderError(
options: renderError.IRenderOptions
): Promise<void> {
// Unpack the options.
const { host, linkHandler, resolver } = options;
renderTextual(options, {
checkWeb: true,
checkPaths: true
});
// Patch the paths if a resolver is available.
let promise: Promise<void>;
if (resolver) {
promise = Private.handlePaths(host, resolver, linkHandler);
} else {
promise = Promise.resolve(undefined);
}
// Return the rendered promise.
return promise;
}
/**
* Merge `<span>` nodes from a `<pre>` element with `<a>` nodes from linker.
*/
function mergeNodes(
preNodes: (Text | HTMLSpanElement)[],
linkedNodes: (Text | HTMLAnchorElement)[]
): HTMLPreElement {
const ret = document.createElement('pre');
let inAnchorElement = false;
const combinedNodes: (HTMLAnchorElement | Text | HTMLSpanElement)[] = [];
for (let nodes of alignedNodes(preNodes, linkedNodes)) {
if (!nodes[0]) {
combinedNodes.push(nodes[1]);
inAnchorElement = nodes[1].nodeType !== Node.TEXT_NODE;
continue;
} else if (!nodes[1]) {
combinedNodes.push(nodes[0]);
inAnchorElement = false;
continue;
}
let [preNode, linkNode] = nodes;
const lastCombined = combinedNodes[combinedNodes.length - 1];
// If we are already in an anchor element and the anchor element did not change,
// we should insert the node from <pre> which is either Text node or coloured span Element
// into the anchor content as a child
if (
inAnchorElement &&
(linkNode as HTMLAnchorElement).href ===
(lastCombined as HTMLAnchorElement).href
) {
lastCombined.appendChild(preNode);
} else {
// the `linkNode` is either Text or AnchorElement;
const isAnchor = linkNode.nodeType !== Node.TEXT_NODE;
// if we are NOT about to start an anchor element, just add the pre Node
if (!isAnchor) {
combinedNodes.push(preNode);
inAnchorElement = false;
} else {
// otherwise start a new anchor; the contents of the `linkNode` and `preNode` should be the same,
// so we just put the neatly formatted `preNode` inside the anchor node (`linkNode`)
// and append that to combined nodes.
linkNode.textContent = '';
linkNode.appendChild(preNode);
combinedNodes.push(linkNode);
inAnchorElement = true;
}
}
}
// Do not reuse `pre` element. Clearing out previous children is too slow...
for (const child of combinedNodes) {
ret.appendChild(child);
}
return ret;
}
/**
* The namespace for the `renderError` function statics.
*/
export namespace renderError {
/**
* The options for the `renderError` function.
*/
export interface IRenderOptions {
/**
* The host node for the error content.
*/
host: HTMLElement;
/**
* The html sanitizer for untrusted source.
*/
sanitizer: IRenderMime.ISanitizer;
/**
* The source error to render.
*/
source: string;
/**
* An optional url resolver.
*/
resolver: IRenderMime.IResolver | null;
/**
* An optional link handler.
*/
linkHandler: IRenderMime.ILinkHandler | null;
/**
* The application language translator.
*/
translator?: ITranslator;
}
}
/**
* The namespace for module implementation details.
*/
namespace Private {
/**
* Cache for auto-linking results to provide better performance when streaming outputs.
*/
export const autoLinkCache = new Map<
string,
WeakMap<HTMLElement, IAutoLinkCacheEntry>
>();
/**
* Eval the script tags contained in a host populated by `innerHTML`.
*
* When script tags are created via `innerHTML`, the browser does not
* evaluate them when they are added to the page. This function works
* around that by creating new equivalent script nodes manually, and
* replacing the originals.
*/
export function evalInnerHTMLScriptTags(host: HTMLElement): void {
// Create a snapshot of the current script nodes.
const scripts = Array.from(host.getElementsByTagName('script'));
// Loop over each script node.
for (const script of scripts) {
// Skip any scripts which no longer have a parent.
if (!script.parentNode) {
continue;
}
// Create a new script node which will be clone.
const clone = document.createElement('script');
// Copy the attributes into the clone.
const attrs = script.attributes;
for (let i = 0, n = attrs.length; i < n; ++i) {
const { name, value } = attrs[i];
clone.setAttribute(name, value);
}
// Copy the text content into the clone.
clone.textContent = script.textContent;
// Replace the old script in the parent.
script.parentNode.replaceChild(clone, script);
}
}
/**
* Handle the default behavior of nodes.
*/
export function handleDefaults(
node: HTMLElement,
resolver?: IRenderMime.IResolver | null
): void {
// Handle anchor elements.
const anchors = node.getElementsByTagName('a');
for (let i = 0; i < anchors.length; i++) {
const el = anchors[i];
// skip when processing a elements inside svg
// which are of type SVGAnimatedString
if (!(el instanceof HTMLAnchorElement)) {
continue;
}
const path = el.href;
const isLocal =
resolver && resolver.isLocal
? resolver.isLocal(path)
: URLExt.isLocal(path);
// set target attribute if not already present
if (!el.target) {
el.target = isLocal ? '_self' : '_blank';
}
// set rel as 'noopener' for non-local anchors
if (!isLocal) {
el.rel = 'noopener';
}
}
// Handle image elements.
const imgs = node.getElementsByTagName('img');
for (let i = 0; i < imgs.length; i++) {
if (!imgs[i].alt) {
imgs[i].alt = 'Image';
}
}
}
/**
* Resolve the relative urls in element `src` and `href` attributes.
*
* @param node - The head html element.
*
* @param resolver - A url resolver.
*
* @param linkHandler - An optional link handler for nodes.
*
* @returns a promise fulfilled when the relative urls have been resolved.
*/
export function handleUrls(
node: HTMLElement,
resolver: IRenderMime.IResolver,
linkHandler: IRenderMime.ILinkHandler | null
): Promise<void> {
// Set up an array to collect promises.
const promises: Promise<void>[] = [];
// Handle HTML Elements with src attributes.
const nodes = node.querySelectorAll('*[src]');
for (let i = 0; i < nodes.length; i++) {
promises.push(handleAttr(nodes[i] as HTMLElement, 'src', resolver));
}
// Handle anchor elements.
const anchors = node.getElementsByTagName('a');
for (let i = 0; i < anchors.length; i++) {
promises.push(handleAnchor(anchors[i], resolver, linkHandler));
}
// Handle link elements.
const links = node.getElementsByTagName('link');
for (let i = 0; i < links.length; i++) {
promises.push(handleAttr(links[i], 'href', resolver));
}
// Wait on all promises.
return Promise.all(promises).then(() => undefined);
}
/**
* Resolve the paths in `<a>` elements `data` attributes.
*
* @param node - The head html element.
*
* @param resolver - A url resolver.
*
* @param linkHandler - An optional link handler for nodes.
*
* @returns a promise fulfilled when the relative urls have been resolved.
*/
export async function handlePaths(
node: HTMLElement,
resolver: IRenderMime.IResolver,
linkHandler: IRenderMime.ILinkHandler | null
): Promise<void> {
// Handle anchor elements.
const anchors = node.getElementsByTagName('a');
for (let i = 0; i < anchors.length; i++) {
await handlePathAnchor(anchors[i], resolver, linkHandler);
}
}
/**
* Apply ids to headers.
*/
export function headerAnchors(node: HTMLElement): void {
const headerNames = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
for (const headerType of headerNames) {
const headers = node.getElementsByTagName(headerType);
for (let i = 0; i < headers.length; i++) {
const header = headers[i];
header.id = renderMarkdown.createHeaderId(header);
const anchor = document.createElement('a');
anchor.target = '_self';
anchor.textContent = '¶';
anchor.href = '#' + header.id;
anchor.classList.add('jp-InternalAnchorLink');
header.appendChild(anchor);
}
}
}
/**
* Handle a node with a `src` or `href` attribute.
*/
async function handleAttr(
node: HTMLElement,
name: 'src' | 'href',
resolver: IRenderMime.IResolver
): Promise<void> {
const source = node.getAttribute(name) || '';
const isLocal = resolver.isLocal
? resolver.isLocal(source)
: URLExt.isLocal(source);
if (!source || !isLocal) {
return;
}
try {
const urlPath = await resolver.resolveUrl(source);
let url = await resolver.getDownloadUrl(urlPath);
if (URLExt.parse(url).protocol !== 'data:') {
// Bust caching for local src attrs.
// https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/Using_XMLHttpRequest#Bypassing_the_cache
url += (/\?/.test(url) ? '&' : '?') + new Date().getTime();
}
node.setAttribute(name, url);
} catch (err) {
// If there was an error getting the url,
// just make it an empty link and report the error.
node.setAttribute(name, '');
throw err;
}
}
/**
* Handle an anchor node.
*/
function handleAnchor(
anchor: HTMLAnchorElement,
resolver: IRenderMime.IResolver,
linkHandler: IRenderMime.ILinkHandler | null
): Promise<void> {
// Get the link path without the location prepended.
// (e.g. "./foo.md#Header 1" vs "http://localhost:8888/foo.md#Header 1")
let href = anchor.getAttribute('href') || '';
const isLocal = resolver.isLocal
? resolver.isLocal(href)
: URLExt.isLocal(href);
// Bail if it is not a file-like url.
if (!href || !isLocal) {
return Promise.resolve(undefined);
}
// Remove the hash until we can handle it.
const hash = anchor.hash;
if (hash) {
// Handle internal link in the file.
if (hash === href) {
anchor.target = '_self';
return Promise.resolve(undefined);
}
// For external links, remove the hash until we have hash handling.
href = href.replace(hash, '');
}
// Get the appropriate file path.
return resolver
.resolveUrl(href)
.then(urlPath => {
// decode encoded url from url to api path
const path = decodeURIComponent(urlPath);
// Handle the click override.
if (linkHandler) {
linkHandler.handleLink(anchor, path, hash);
}
// Get the appropriate file download path.
return resolver.getDownloadUrl(urlPath);
})
.then(url => {
// Set the visible anchor.
anchor.href = url + hash;
})
.catch(err => {
// If there was an error getting the url,
// just make it an empty link.
anchor.href = '';
});
}
/**
* Handle an anchor node.
*/
async function handlePathAnchor(
anchor: HTMLAnchorElement,
resolver: IRenderMime.IResolver,
linkHandler: IRenderMime.ILinkHandler | null
): Promise<void> {
let path = anchor.dataset.path || '';
let locator = anchor.dataset.locator ? '#' + anchor.dataset.locator : '';
delete anchor.dataset.path;
delete anchor.dataset.locator;
const allowRoot = true;
const isLocal = resolver.isLocal
? resolver.isLocal(path, allowRoot)
: URLExt.isLocal(path, allowRoot);
// Bail if:
// - it is not a file-like url,
// - the resolver does not support paths
// - there is no link handler, or if it does not support paths
if (
!path ||
!isLocal ||
!resolver.resolvePath ||
!linkHandler ||
!linkHandler.handlePath
) {
anchor.replaceWith(...anchor.childNodes);
return Promise.resolve(undefined);
}
try {
// Find given path
const resolution = await resolver.resolvePath(path);
if (!resolution) {
// Bail if the file does not exist
console.log('Path resolution bailing: does not exist');
return Promise.resolve(undefined);
}
// Handle the click override.
linkHandler.handlePath(
anchor,
resolution.path,
resolution.scope,
locator
);
// Set the visible anchor.
anchor.href = resolution.path + locator;
} catch (err) {
// If there was an error getting the url,
// just make it an empty link.
console.warn('Path anchor error:', err);
anchor.href = '#linking-failed-see-console';
}
}
const ANSI_COLORS = [
'ansi-black',
'ansi-red',
'ansi-green',
'ansi-yellow',
'ansi-blue',
'ansi-magenta',
'ansi-cyan',
'ansi-white',
'ansi-black-intense',
'ansi-red-intense',
'ansi-green-intense',
'ansi-yellow-intense',
'ansi-blue-intense',
'ansi-magenta-intense',
'ansi-cyan-intense',
'ansi-white-intense'
];
/**
* Create HTML tags for a string with given foreground, background etc. and
* add them to the `out` array.
*/
function pushColoredChunk(
chunk: string,
fg: number | Array<number>,
bg: number | Array<number>,
bold: boolean,
underline: boolean,
inverse: boolean,
out: Array<string>
): void {
if (chunk) {
const classes = [];
const styles = [];
if (bold && typeof fg === 'number' && 0 <= fg && fg < 8) {
fg += 8; // Bold text uses "intense" colors
}
if (inverse) {
[fg, bg] = [bg, fg];
}
if (typeof fg === 'number') {
classes.push(ANSI_COLORS[fg] + '-fg');
} else if (fg.length) {
styles.push(`color: rgb(${fg})`);
} else if (inverse) {
classes.push('ansi-default-inverse-fg');
}
if (typeof bg === 'number') {
classes.push(ANSI_COLORS[bg] + '-bg');
} else if (bg.length) {
styles.push(`background-color: rgb(${bg})`);
} else if (inverse) {
classes.push('ansi-default-inverse-bg');
}
if (bold) {
classes.push('ansi-bold');
}
if (underline) {
classes.push('ansi-underline');
}
if (classes.length || styles.length) {
out.push('<span');
if (classes.length) {
out.push(` class="${classes.join(' ')}"`);
}
if (styles.length) {
out.push(` style="${styles.join('; ')}"`);
}
out.push('>');
out.push(chunk);
out.push('</span>');
} else {
out.push(chunk);
}
}
}
/**
* Convert ANSI extended colors to R/G/B triple.
*/
function getExtendedColors(numbers: Array<number>): number | Array<number> {
let r;
let g;
let b;
const n = numbers.shift();
if (n === 2 && numbers.length >= 3) {
// 24-bit RGB
r = numbers.shift()!;
g = numbers.shift()!;
b = numbers.shift()!;
if ([r, g, b].some(c => c < 0 || 255 < c)) {
throw new RangeError('Invalid range for RGB colors');
}
} else if (n === 5 && numbers.length >= 1) {
// 256 colors
const idx = numbers.shift()!;
if (idx < 0) {
throw new RangeError('Color index must be >= 0');
} else if (idx < 16) {
// 16 default terminal colors
return idx;
} else if (idx < 232) {
// 6x6x6 color cube, see https://stackoverflow.com/a/27165165/500098
r = Math.floor((idx - 16) / 36);
r = r > 0 ? 55 + r * 40 : 0;
g = Math.floor(((idx - 16) % 36) / 6);
g = g > 0 ? 55 + g * 40 : 0;
b = (idx - 16) % 6;
b = b > 0 ? 55 + b * 40 : 0;
} else if (idx < 256) {
// grayscale, see https://stackoverflow.com/a/27165165/500098
r = g = b = (idx - 232) * 10 + 8;
} else {
throw new RangeError('Color index must be < 256');
}
} else {
throw new RangeError('Invalid extended color specification');
}
return [r, g, b];
}
/**
* Transform ANSI color escape codes into HTML <span> tags with CSS
* classes such as "ansi-green-intense-fg".
* The actual colors used are set in the CSS file.
* This also removes non-color escape sequences.
* This is supposed to have the same behavior as nbconvert.filters.ansi2html()
*/
export function ansiSpan(str: string): string {
const ansiRe = /\x1b\[(.*?)([@-~])/g; // eslint-disable-line no-control-regex
let fg: number | Array<number> = [];
let bg: number | Array<number> = [];
let bold = false;
let underline = false;
let inverse = false;
let match;
const out: Array<string> = [];
const numbers = [];
let start = 0;
str = escape(str);
str += '\x1b[m'; // Ensure markup for trailing text
// tslint:disable-next-line
while ((match = ansiRe.exec(str))) {
if (match[2] === 'm') {
const items = match[1].split(';');
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item === '') {
numbers.push(0);
} else if (item.search(/^\d+$/) !== -1) {
numbers.push(parseInt(item, 10));
} else {
// Ignored: Invalid color specification
numbers.length = 0;
break;
}
}
} else {
// Ignored: Not a color code
}
const chunk = str.substring(start, match.index);
pushColoredChunk(chunk, fg, bg, bold, underline, inverse, out);
start = ansiRe.lastIndex;
while (numbers.length) {
const n = numbers.shift();
switch (n) {
case 0:
fg = bg = [];
bold = false;
underline = false;
inverse = false;
break;
case 1:
case 5:
bold = true;
break;
case 4:
underline = true;
break;
case 7:
inverse = true;
break;
case 21:
case 22:
bold = false;
break;
case 24:
underline = false;
break;
case 27:
inverse = false;
break;
case 30:
case 31:
case 32:
case 33:
case 34:
case 35:
case 36:
case 37:
fg = n - 30;
break;
case 38:
try {
fg = getExtendedColors(numbers);
} catch (e) {
numbers.length = 0;
}
break;
case 39:
fg = [];
break;
case 40:
case 41:
case 42:
case 43:
case 44:
case 45:
case 46:
case 47:
bg = n - 40;
break;
case 48:
try {
bg = getExtendedColors(numbers);
} catch (e) {
numbers.length = 0;
}
break;
case 49:
bg = [];
break;
case 90:
case 91:
case 92:
case 93:
case 94:
case 95:
case 96:
case 97:
fg = n - 90 + 8;
break;
case 100:
case 101:
case 102:
case 103:
case 104:
case 105:
case 106:
case 107:
bg = n - 100 + 8;
break;
default:
// Unknown codes are ignored
}
}
}
return out.join('');
}
}