@mintlify/common
Version:
Commonly shared code within Mintlify
91 lines (90 loc) • 3.52 kB
JavaScript
const HEADING_ID_RE = /^(#{1,6})\s+(.+?)\s*\{\s*#([^}]+?)\s*\}\s*$/;
const UNSAFE_HTML_ATTR_CHARS = /[<>"]/g;
/**
* Characters to strip from custom heading IDs in the default (non-editor) path.
* Combines HTML-unsafe characters, `&` (non-conformant in attributes), and the
* punctuation that `cleanHeadingId` strips at render time so the preprocessed ID
* matches the final DOM `id`.
*/
const CUSTOM_ID_STRIP_CHARS = /[<>"&?,;:!'()\[\]{}]/g;
const EMOJI_RE = /\p{Emoji_Modifier}|\p{Emoji_Modifier_Base}|\p{Emoji_Presentation}|\p{Extended_Pictographic}|[\u200D\uFE0E\uFE0F]/gu;
function sanitizeHtmlAttr(id) {
return id.trim().replace(UNSAFE_HTML_ATTR_CHARS, '');
}
function normalizeCustomId(raw) {
return raw.trim().replace(CUSTOM_ID_STRIP_CHARS, '').replace(EMOJI_RE, '').replace(/\s+/g, '-');
}
/**
* Converts markdown headings with custom ID syntax into JSX heading elements.
*
* Transforms `## Heading text {#custom-id}` into `<h2 id="custom-id">Heading text</h2>`.
* This avoids MDX parsing errors (MDX treats `{...}` as JS expressions) and
* produces JSX heading elements that the existing remark pipeline already handles.
*
* Uses a line-by-line scan with fence state tracking to skip headings inside
* code blocks, avoiding backtracking over large documents.
*/
export function preprocessCustomHeadingIds(content, options) {
const lines = content.split('\n');
const result = [];
let fenceChar;
let fenceCount = 0;
for (const line of lines) {
const stripped = line.trimStart();
if (fenceChar !== undefined) {
if (isClosingFence(stripped, fenceChar, fenceCount)) {
fenceChar = undefined;
}
result.push(line);
continue;
}
const fence = parseOpeningFence(stripped);
if (fence) {
fenceChar = fence.char;
fenceCount = fence.count;
result.push(line);
continue;
}
const heading = HEADING_ID_RE.exec(line);
if ((heading === null || heading === void 0 ? void 0 : heading[1]) && heading[2] && heading[3]) {
const level = heading[1].length;
const sanitizedId = (options === null || options === void 0 ? void 0 : options.preserveOriginal)
? sanitizeHtmlAttr(heading[3])
: normalizeCustomId(heading[3]);
result.push(`<h${level} id="${sanitizedId}">`, heading[2], `</h${level}>`);
continue;
}
result.push(line);
}
return result.join('\n');
}
function parseOpeningFence(stripped) {
const char = stripped[0];
if (char !== '`' && char !== '~')
return undefined;
let count = 1;
while (count < stripped.length && stripped[count] === char)
count++;
if (count < 3)
return undefined;
return { char, count };
}
function isClosingFence(stripped, fenceChar, fenceCount) {
let i = 0;
while (i < stripped.length && stripped[i] === fenceChar)
i++;
if (i < fenceCount)
return false;
for (; i < stripped.length; i++) {
if (stripped[i] !== ' ' && stripped[i] !== '\t' && stripped[i] !== '\r')
return false;
}
return true;
}
export const ALL_HEADING_NAMES = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
export const isJsxHeadingElement = (node) => {
return (node.type === 'mdxJsxFlowElement' &&
'name' in node &&
typeof node.name === 'string' &&
ALL_HEADING_NAMES.includes(node.name));
};