import type { HTMLAttributes } from 'react'; import { Marked } from 'marked'; import { markedSmartypants } from 'marked-smartypants'; import type { MarkdownOptions } from '../types'; export const DEFAULT_SLIDE_SEPARATOR = '\r?\n---\r?\n'; export const DEFAULT_VERTICAL_SEPARATOR = null; export const DEFAULT_NOTES_SEPARATOR = '^\\s*notes?:'; export const DEFAULT_ELEMENT_ATTRIBUTES_SEPARATOR = '\\.element\\s*?(.+?)$'; export const DEFAULT_SLIDE_ATTRIBUTES_SEPARATOR = '\\.slide:\\s*?(\\S.+?)$'; const CODE_LINE_NUMBER_REGEX = /\[\s*((\d*):)?\s*([\s\d,|-]*)\]/; const HTML_ESCAPE_MAP: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }; type MarkdownLeaf = { key: string; html: string; }; export type MarkdownRenderNode = | { type: 'slide'; key: string; slide: MarkdownLeaf; } | { type: 'stack'; key: string; slides: MarkdownLeaf[]; }; export function normalizeMarkdownSource(markdown: string) { const leadingWhitespace = markdown.match(/^\n?(\s*)/)?.[1].length ?? 0; const leadingTabs = markdown.match(/^\n?(\t*)/)?.[1].length ?? 0; if (leadingTabs > 0) { return markdown.replace(new RegExp(`\\n?\\t{${leadingTabs}}(.*)`, 'g'), (_match, line) => { return `\n${line}`; }); } if (leadingWhitespace > 1) { return markdown.replace(new RegExp(`\\n? {${leadingWhitespace}}(.*)`, 'g'), (_match, line) => { return `\n${line}`; }); } return markdown; } function escapeForHTML(input: string) { return input.replace(/([&<>'"])/g, (char) => HTML_ESCAPE_MAP[char]); } export function createMarkedInstance(options: MarkdownOptions = {}) { const { renderer: customRenderer, animateLists, smartypants, ...markedOptions } = options; const renderer = customRenderer || ({ code({ text, lang }: { text: string; lang?: string }) { let language = lang || ''; let lineNumberOffset = ''; let lineNumbers = ''; if (CODE_LINE_NUMBER_REGEX.test(language)) { const lineNumberOffsetMatch = language.match(CODE_LINE_NUMBER_REGEX)?.[2]; if (lineNumberOffsetMatch) { lineNumberOffset = `data-ln-start-from="${lineNumberOffsetMatch.trim()}"`; } lineNumbers = language.match(CODE_LINE_NUMBER_REGEX)?.[3]?.trim() || ''; lineNumbers = `data-line-numbers="${lineNumbers}"`; language = language.replace(CODE_LINE_NUMBER_REGEX, '').trim(); } text = escapeForHTML(text); return `
${text}
`; }, } satisfies Record); if (animateLists === true && !customRenderer) { (renderer as { listitem?: (this: any, token: any) => string }).listitem = function ( this: { parser: { parseInline(tokens: unknown[]): string } }, token: { tokens?: unknown[]; text?: string } ) { const text = token.tokens ? this.parser.parseInline(token.tokens) : token.text || ''; return `
  • ${text}
  • `; }; } const markedInstance = new Marked(); markedInstance.use({ renderer, ...markedOptions }); if (smartypants) { markedInstance.use(markedSmartypants()); } return markedInstance; } function ensureParsedMarkdown(result: string | Promise) { if (typeof result === 'string') return result; throw new Error( 'Async markdown parsing is not supported here because Reveal markdown parsing is synchronous.' ); } function createSlideHtml(markdown: string, markedInstance: Marked, notesSeparator: string) { const notesMatch = markdown.split(new RegExp(notesSeparator, 'mgi')); let slideMarkdown = markdown; let notesHtml = ''; if (notesMatch.length === 2) { slideMarkdown = notesMatch[0]; notesHtml = ``; } return `${ensureParsedMarkdown(markedInstance.parse(slideMarkdown))}${notesHtml}`; } export function buildMarkdownNodes( markdown: string, markedInstance: Marked, separator: string, verticalSeparator: string | null, notesSeparator: string ): MarkdownRenderNode[] { const separatorRegex = new RegExp( separator + (verticalSeparator ? `|${verticalSeparator}` : ''), 'mg' ); const horizontalSeparatorRegex = new RegExp(separator); let matches: RegExpExecArray | null; let lastIndex = 0; let isHorizontal = true; let wasHorizontal = true; const sectionStack: Array = []; // This mirrors the core plugin's slidify pass so that horizontal and vertical // separators produce the exact same section nesting as reveal.js markdown does. while ((matches = separatorRegex.exec(markdown))) { isHorizontal = horizontalSeparatorRegex.test(matches[0]); if (!isHorizontal && wasHorizontal) { sectionStack.push([]); } const content = markdown.substring(lastIndex, matches.index); if (isHorizontal && wasHorizontal) { sectionStack.push(content); } else { (sectionStack[sectionStack.length - 1] as string[]).push(content); } lastIndex = separatorRegex.lastIndex; wasHorizontal = isHorizontal; } (wasHorizontal ? sectionStack : (sectionStack[sectionStack.length - 1] as string[])).push( markdown.substring(lastIndex) ); return sectionStack.map((entry, horizontalIndex) => { if (Array.isArray(entry)) { return { type: 'stack' as const, key: `h${horizontalIndex}`, slides: entry.map((slideMarkdown, verticalIndex) => ({ key: `h${horizontalIndex}-v${verticalIndex}`, html: createSlideHtml(slideMarkdown, markedInstance, notesSeparator), })), }; } return { type: 'slide' as const, key: `h${horizontalIndex}`, slide: { key: `h${horizontalIndex}`, html: createSlideHtml(entry, markedInstance, notesSeparator), }, }; }); } function addAttributeInElement(node: ChildNode, elementTarget: Element | null, separator: string) { if (!elementTarget || node.nodeType !== Node.COMMENT_NODE || node.nodeValue == null) { return false; } const markdownClassesInElementsRegex = new RegExp(separator, 'mg'); const markdownClassRegex = new RegExp('([^"= ]+?)="([^"]+?)"|(data-[^"= ]+?)(?=[" ])', 'mg'); let nodeValue = node.nodeValue; const matches = markdownClassesInElementsRegex.exec(nodeValue); if (!matches) return false; const classes = matches[1]; nodeValue = nodeValue.substring(0, matches.index) + nodeValue.substring(markdownClassesInElementsRegex.lastIndex); node.nodeValue = nodeValue; let matchesClass: RegExpExecArray | null; while ((matchesClass = markdownClassRegex.exec(classes))) { if (matchesClass[2]) { elementTarget.setAttribute(matchesClass[1], matchesClass[2]); } else { elementTarget.setAttribute(matchesClass[3], ''); } } return true; } export function addAttributes( section: HTMLElement, element: ChildNode | HTMLElement, previousElement: Element | null, separatorElementAttributes: string, separatorSectionAttributes: string ) { if ('childNodes' in element && element.childNodes.length > 0) { let previousParentElement: Element | null = element instanceof Element ? element : previousElement; for (let index = 0; index < element.childNodes.length; index += 1) { const childElement = element.childNodes[index]; if (index > 0) { let previousIndex = index - 1; while (previousIndex >= 0) { const previousChildElement = element.childNodes[previousIndex]; if ( typeof (previousChildElement as Element).setAttribute === 'function' && (previousChildElement as Element).tagName !== 'BR' ) { previousParentElement = previousChildElement as Element; break; } previousIndex -= 1; } } let parentSection = section; if ((childElement as Element).nodeName === 'SECTION') { parentSection = childElement as HTMLElement; previousParentElement = childElement as Element; } if ( typeof (childElement as Element).setAttribute === 'function' || childElement.nodeType === Node.COMMENT_NODE ) { addAttributes( parentSection, childElement as ChildNode | HTMLElement, previousParentElement, separatorElementAttributes, separatorSectionAttributes ); } } } if (element.nodeType !== Node.COMMENT_NODE) return; let targetElement = previousElement; if (targetElement && (targetElement.tagName === 'UL' || targetElement.tagName === 'OL')) { targetElement = targetElement.lastElementChild || targetElement; } if (addAttributeInElement(element, targetElement, separatorElementAttributes) === false) { addAttributeInElement(element, section, separatorSectionAttributes); } } export function hashString(input: string) { let hash = 5381; for (let index = 0; index < input.length; index += 1) { hash = (hash * 33) ^ input.charCodeAt(index); } return (hash >>> 0).toString(36); } function serializeSignatureValue(value: unknown): unknown { if ( value == null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ) { return value; } if (Array.isArray(value)) { return value.map(serializeSignatureValue); } if (typeof value === 'object') { return Object.entries(value as Record) .filter(([, entryValue]) => entryValue !== undefined && typeof entryValue !== 'function') .sort(([a], [b]) => a.localeCompare(b)) .map(([key, entryValue]) => [key, serializeSignatureValue(entryValue)]); } return String(value); } export function getSectionPropsSignature(attributes: HTMLAttributes) { return JSON.stringify( Object.entries(attributes) .filter(([, value]) => value !== undefined && typeof value !== 'function') .sort(([a], [b]) => a.localeCompare(b)) .map(([key, value]) => [key, serializeSignatureValue(value)]) ); } export function getErrorMessage(error: unknown) { if (error instanceof Error && error.message) return error.message; return String(error); }