UNPKG

@mintlify/common

Version:

Commonly shared code within Mintlify

91 lines (90 loc) 3.52 kB
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)); };