@jupyterlab/toc
Version:
JupyterLab - Table of Contents widget
270 lines • 8.65 kB
JavaScript
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
import { Sanitizer } from '@jupyterlab/apputils';
import { renderMarkdown } from '@jupyterlab/rendermime';
/**
* Build the heading html id.
*
* @param raw Raw markdown heading
* @param level Heading level
* @param sanitizer HTML sanitizer
*/
export async function getHeadingId(markdownParser, raw, level, sanitizer) {
try {
const host = document.createElement('div');
await renderMarkdown({
markdownParser,
host,
source: raw,
trusted: false,
sanitizer: sanitizer !== null && sanitizer !== void 0 ? sanitizer : new Sanitizer(),
shouldTypeset: false,
resolver: null,
linkHandler: null,
latexTypesetter: null
});
const header = host.querySelector(`h${level}`);
if (!header) {
return null;
}
return (sanitizer === null || sanitizer === void 0 ? void 0 : sanitizer.allowNamedProperties)
? header.id
: header.getAttribute('data-jupyter-id');
}
catch (reason) {
console.error('Failed to parse a heading.', reason);
}
return null;
}
/**
* Parses the provided string and returns a list of headings.
*
* @param text - Markdown text
* @param parser - A Markdown parser instance used to render the content to HTML.
* @returns List of headings
*
* @remarks
* Renders Markdown to HTML and extracts `<h1>`–`<h6>` elements.
* Returns an empty list if the parser is `null`.
*/
export async function parseHeadings(markdownText, parser) {
if (!parser || !parser.getHeadingTokens) {
console.warn('Unable to parse headings; Markdown parser is missing or does not implement getHeadingTokens().');
return [];
}
// Get the heading tokens from the parser, including the source line metadata
const headingTokens = await parser.getHeadingTokens(markdownText);
// Convert each heading token into HTML and extract the heading elements,
// as this provides a clean and reliable way to retrieve the headings.
const parseHeadings = await Promise.all(headingTokens.map(async (token) => {
const renderedHtml = await parser.render(token.raw);
const dom = new DOMParser().parseFromString(renderedHtml, 'text/html');
const htmlHeadings = Array.from(dom.querySelectorAll('h1, h2, h3, h4, h5, h6'));
return htmlHeadings.map(e => {
var _a;
return ({
text: ((_a = e.textContent) === null || _a === void 0 ? void 0 : _a.trim()) || '',
line: token.line,
level: parseInt(e.tagName[1], 10),
raw: token.raw.replace(/\n+$/, ''),
skip: skipHeading.test(token.raw)
});
});
}));
return parseHeadings.reduce((acc, curr) => acc.concat(curr), []);
}
/**
* @deprecated in favour of async parseHeadings()
*
* Parses the provided string and returns a list of headings.
*
* @param text - Input text
* @returns List of headings
*
*/
export function getHeadings(text) {
// Split the text into lines:
const lines = text.split('\n');
// Iterate over the lines to get the header level and text for each line:
const headings = new Array();
let isCodeBlock;
let openingFence = 0;
let fenceType;
let lineIdx = 0;
// Don't check for Markdown headings if in a YAML frontmatter block.
// We can only start a frontmatter block on the first line of the file.
// At other positions in a markdown file, '---' represents a horizontal rule.
if (lines[lineIdx] === '---') {
// Search for another '---' and treat that as the end of the frontmatter.
// If we don't find one, treat the file as containing no frontmatter.
for (let frontmatterEndLineIdx = lineIdx + 1; frontmatterEndLineIdx < lines.length; frontmatterEndLineIdx++) {
if (lines[frontmatterEndLineIdx] === '---') {
lineIdx = frontmatterEndLineIdx + 1;
break;
}
}
}
for (; lineIdx < lines.length; lineIdx++) {
const line = lines[lineIdx];
if (line === '') {
// Bail early
continue;
}
// Don't check for Markdown headings if in a code block
if (line.startsWith('```') || line.startsWith('~~~')) {
const closingFence = extractLeadingFences(line);
if (closingFence === 0)
continue;
if (openingFence === 0) {
fenceType = line.charAt(0);
isCodeBlock = !isCodeBlock;
openingFence = closingFence;
continue;
}
else if (fenceType === line.charAt(0) && closingFence >= openingFence) {
isCodeBlock = !isCodeBlock;
openingFence = 0;
fenceType = '';
}
}
if (isCodeBlock) {
continue;
}
const heading = parseHeading(line, lines[lineIdx + 1]); // append the next line to capture alternative style Markdown headings
if (heading) {
headings.push({
...heading,
line: lineIdx
});
}
}
return headings;
}
// Returns the length of ``` or ~~~ fences.
function extractLeadingFences(line) {
let match;
if (line.startsWith('`'))
match = line.match(/^`{3,}/);
else
match = line.match(/^~{3,}/);
return match ? match[0].length : 0;
}
const MARKDOWN_MIME_TYPE = [
'text/x-ipythongfm',
'text/x-markdown',
'text/x-gfm',
'text/markdown'
];
/**
* Returns whether a MIME type corresponds to a Markdown flavor.
*
* @param mime - MIME type string
* @returns boolean indicating whether a provided MIME type corresponds to a Markdown flavor
*
* @example
* const bool = isMarkdown('text/markdown');
* // returns true
*
* @example
* const bool = isMarkdown('text/plain');
* // returns false
*/
export function isMarkdown(mime) {
return MARKDOWN_MIME_TYPE.includes(mime);
}
/**
* Parses a heading, if one exists, from a provided string.
*
* ## Notes
*
* - Heading examples:
*
* - Markdown heading:
*
* ```
* # Foo
* ```
*
* - Markdown heading (alternative style):
*
* ```
* Foo
* ===
* ```
*
* ```
* Foo
* ---
* ```
*
* - HTML heading:
*
* ```
* <h3>Foo</h3>
* ```
*
* @private
* @param line - Line to parse
* @param nextLine - The line after the one to parse
* @returns heading info
*
* @example
* const out = parseHeading('### Foo\n');
* // returns {'text': 'Foo', 'level': 3}
*
* @example
* const out = parseHeading('Foo\n===\n');
* // returns {'text': 'Foo', 'level': 1}
*
* @example
* const out = parseHeading('<h4>Foo</h4>\n');
* // returns {'text': 'Foo', 'level': 4}
*
* @example
* const out = parseHeading('Foo');
* // returns null
*/
function parseHeading(line, nextLine) {
// Case: Markdown heading
let match = line.match(/^(#{1,6}) (.*)/);
if (match) {
return {
text: cleanTitle(match[2]),
level: match[1].length,
raw: line,
skip: skipHeading.test(match[0])
};
}
// Case: Markdown heading (alternative style)
if (nextLine) {
match = nextLine.match(/^ {0,3}([=]{2,}|-{2,})\s*$/);
if (match) {
return {
text: cleanTitle(line),
level: match[1][0] === '=' ? 1 : 2,
raw: [line, nextLine].join('\n'),
skip: skipHeading.test(line)
};
}
}
// Case: HTML heading (WARNING: this is not particularly robust, as HTML headings can span multiple lines)
match = line.match(/<h([1-6]).*>(.*)<\/h\1>/i); // eslint-disable-line regexp/no-super-linear-backtracking
if (match) {
return {
text: match[2],
level: parseInt(match[1], 10),
skip: skipHeading.test(match[0]),
raw: line
};
}
return null;
}
function cleanTitle(heading) {
// take special care to parse Markdown links into raw text
return heading.replace(/\[(.+)\]\(.+\)/g, '$1');
}
/**
* Ignore title with html tag with a class name equal to `jp-toc-ignore` or `tocSkip`
*/
const skipHeading = /<\w+\s(?:.*?\s)?class="(?:.*?\s)?(?:jp-toc-ignore|tocSkip)(?:\s.*?)?"(?:\s.*?)?>/;
//# sourceMappingURL=markdown.js.map