| 1 | import type { HTMLAttributes } from 'react'; |
| 2 | import { Marked } from 'marked'; |
| 3 | import { markedSmartypants } from 'marked-smartypants'; |
| 4 | import type { MarkdownOptions } from '../types'; |
| 5 | |
| 6 | export const DEFAULT_SLIDE_SEPARATOR = '\r?\n---\r?\n'; |
| 7 | export const DEFAULT_VERTICAL_SEPARATOR = null; |
| 8 | export const DEFAULT_NOTES_SEPARATOR = '^\\s*notes?:'; |
| 9 | export const DEFAULT_ELEMENT_ATTRIBUTES_SEPARATOR = '\\.element\\s*?(.+?)$'; |
| 10 | export const DEFAULT_SLIDE_ATTRIBUTES_SEPARATOR = '\\.slide:\\s*?(\\S.+?)$'; |
| 11 | |
| 12 | const CODE_LINE_NUMBER_REGEX = /\[\s*((\d*):)?\s*([\s\d,|-]*)\]/; |
| 13 | |
| 14 | const HTML_ESCAPE_MAP: Record<string, string> = { |
| 15 | '&': '&', |
| 16 | '<': '<', |
| 17 | '>': '>', |
| 18 | '"': '"', |
| 19 | "'": ''', |
| 20 | }; |
| 21 | |
| 22 | type MarkdownLeaf = { |
| 23 | key: string; |
| 24 | html: string; |
| 25 | }; |
| 26 | |
| 27 | export type MarkdownRenderNode = |
| 28 | | { |
| 29 | type: 'slide'; |
| 30 | key: string; |
| 31 | slide: MarkdownLeaf; |
| 32 | } |
| 33 | | { |
| 34 | type: 'stack'; |
| 35 | key: string; |
| 36 | slides: MarkdownLeaf[]; |
| 37 | }; |
| 38 | |
| 39 | export function normalizeMarkdownSource(markdown: string) { |
| 40 | const leadingWhitespace = markdown.match(/^\n?(\s*)/)?.[1].length ?? 0; |
| 41 | const leadingTabs = markdown.match(/^\n?(\t*)/)?.[1].length ?? 0; |
| 42 | |
| 43 | if (leadingTabs > 0) { |
| 44 | return markdown.replace(new RegExp(`\\n?\\t{${leadingTabs}}(.*)`, 'g'), (_match, line) => { |
| 45 | return `\n${line}`; |
| 46 | }); |
| 47 | } |
| 48 | |
| 49 | if (leadingWhitespace > 1) { |
| 50 | return markdown.replace(new RegExp(`\\n? {${leadingWhitespace}}(.*)`, 'g'), (_match, line) => { |
| 51 | return `\n${line}`; |
| 52 | }); |
| 53 | } |
| 54 | |
| 55 | return markdown; |
| 56 | } |
| 57 | |
| 58 | function escapeForHTML(input: string) { |
| 59 | return input.replace(/([&<>'"])/g, (char) => HTML_ESCAPE_MAP[char]); |
| 60 | } |
| 61 | |
| 62 | export function createMarkedInstance(options: MarkdownOptions = {}) { |
| 63 | const { renderer: customRenderer, animateLists, smartypants, ...markedOptions } = options; |
| 64 | |
| 65 | const renderer = |
| 66 | customRenderer || |
| 67 | ({ |
| 68 | code({ text, lang }: { text: string; lang?: string }) { |
| 69 | let language = lang || ''; |
| 70 | let lineNumberOffset = ''; |
| 71 | let lineNumbers = ''; |
| 72 | |
| 73 | if (CODE_LINE_NUMBER_REGEX.test(language)) { |
| 74 | const lineNumberOffsetMatch = language.match(CODE_LINE_NUMBER_REGEX)?.[2]; |
| 75 | if (lineNumberOffsetMatch) { |
| 76 | lineNumberOffset = `data-ln-start-from="${lineNumberOffsetMatch.trim()}"`; |
| 77 | } |
| 78 | |
| 79 | lineNumbers = language.match(CODE_LINE_NUMBER_REGEX)?.[3]?.trim() || ''; |
| 80 | lineNumbers = `data-line-numbers="${lineNumbers}"`; |
| 81 | language = language.replace(CODE_LINE_NUMBER_REGEX, '').trim(); |
| 82 | } |
| 83 | |
| 84 | text = escapeForHTML(text); |
| 85 | |
| 86 | return `<pre><code ${lineNumbers} ${lineNumberOffset} class="${language}">${text}</code></pre>`; |
| 87 | }, |
| 88 | } satisfies Record<string, unknown>); |
| 89 | |
| 90 | if (animateLists === true && !customRenderer) { |
| 91 | (renderer as { listitem?: (this: any, token: any) => string }).listitem = function ( |
| 92 | this: { parser: { parseInline(tokens: unknown[]): string } }, |
| 93 | token: { tokens?: unknown[]; text?: string } |
| 94 | ) { |
| 95 | const text = token.tokens ? this.parser.parseInline(token.tokens) : token.text || ''; |
| 96 | return `<li class="fragment">${text}</li>`; |
| 97 | }; |
| 98 | } |
| 99 | |
| 100 | const markedInstance = new Marked(); |
| 101 | markedInstance.use({ renderer, ...markedOptions }); |
| 102 | |
| 103 | if (smartypants) { |
| 104 | markedInstance.use(markedSmartypants()); |
| 105 | } |
| 106 | |
| 107 | return markedInstance; |
| 108 | } |
| 109 | |
| 110 | function ensureParsedMarkdown(result: string | Promise<string>) { |
| 111 | if (typeof result === 'string') return result; |
| 112 | |
| 113 | throw new Error( |
| 114 | 'Async markdown parsing is not supported here because Reveal markdown parsing is synchronous.' |
| 115 | ); |
| 116 | } |
| 117 | |
| 118 | function createSlideHtml(markdown: string, markedInstance: Marked, notesSeparator: string) { |
| 119 | const notesMatch = markdown.split(new RegExp(notesSeparator, 'mgi')); |
| 120 | let slideMarkdown = markdown; |
| 121 | let notesHtml = ''; |
| 122 | |
| 123 | if (notesMatch.length === 2) { |
| 124 | slideMarkdown = notesMatch[0]; |
| 125 | notesHtml = `<aside class="notes">${ensureParsedMarkdown( |
| 126 | markedInstance.parse(notesMatch[1].trim()) |
| 127 | )}</aside>`; |
| 128 | } |
| 129 | |
| 130 | return `${ensureParsedMarkdown(markedInstance.parse(slideMarkdown))}${notesHtml}`; |
| 131 | } |
| 132 | |
| 133 | export function buildMarkdownNodes( |
| 134 | markdown: string, |
| 135 | markedInstance: Marked, |
| 136 | separator: string, |
| 137 | verticalSeparator: string | null, |
| 138 | notesSeparator: string |
| 139 | ): MarkdownRenderNode[] { |
| 140 | const separatorRegex = new RegExp( |
| 141 | separator + (verticalSeparator ? `|${verticalSeparator}` : ''), |
| 142 | 'mg' |
| 143 | ); |
| 144 | const horizontalSeparatorRegex = new RegExp(separator); |
| 145 | |
| 146 | let matches: RegExpExecArray | null; |
| 147 | let lastIndex = 0; |
| 148 | let isHorizontal = true; |
| 149 | let wasHorizontal = true; |
| 150 | const sectionStack: Array<string | string[]> = []; |
| 151 | |
| 152 | // This mirrors the core plugin's slidify pass so that horizontal and vertical |
| 153 | // separators produce the exact same section nesting as reveal.js markdown does. |
| 154 | while ((matches = separatorRegex.exec(markdown))) { |
| 155 | isHorizontal = horizontalSeparatorRegex.test(matches[0]); |
| 156 | |
| 157 | if (!isHorizontal && wasHorizontal) { |
| 158 | sectionStack.push([]); |
| 159 | } |
| 160 | |
| 161 | const content = markdown.substring(lastIndex, matches.index); |
| 162 | |
| 163 | if (isHorizontal && wasHorizontal) { |
| 164 | sectionStack.push(content); |
| 165 | } else { |
| 166 | (sectionStack[sectionStack.length - 1] as string[]).push(content); |
| 167 | } |
| 168 | |
| 169 | lastIndex = separatorRegex.lastIndex; |
| 170 | wasHorizontal = isHorizontal; |
| 171 | } |
| 172 | |
| 173 | (wasHorizontal ? sectionStack : (sectionStack[sectionStack.length - 1] as string[])).push( |
| 174 | markdown.substring(lastIndex) |
| 175 | ); |
| 176 | |
| 177 | return sectionStack.map((entry, horizontalIndex) => { |
| 178 | if (Array.isArray(entry)) { |
| 179 | return { |
| 180 | type: 'stack' as const, |
| 181 | key: `h${horizontalIndex}`, |
| 182 | slides: entry.map((slideMarkdown, verticalIndex) => ({ |
| 183 | key: `h${horizontalIndex}-v${verticalIndex}`, |
| 184 | html: createSlideHtml(slideMarkdown, markedInstance, notesSeparator), |
| 185 | })), |
| 186 | }; |
| 187 | } |
| 188 | |
| 189 | return { |
| 190 | type: 'slide' as const, |
| 191 | key: `h${horizontalIndex}`, |
| 192 | slide: { |
| 193 | key: `h${horizontalIndex}`, |
| 194 | html: createSlideHtml(entry, markedInstance, notesSeparator), |
| 195 | }, |
| 196 | }; |
| 197 | }); |
| 198 | } |
| 199 | |
| 200 | function addAttributeInElement(node: ChildNode, elementTarget: Element | null, separator: string) { |
| 201 | if (!elementTarget || node.nodeType !== Node.COMMENT_NODE || node.nodeValue == null) { |
| 202 | return false; |
| 203 | } |
| 204 | |
| 205 | const markdownClassesInElementsRegex = new RegExp(separator, 'mg'); |
| 206 | const markdownClassRegex = new RegExp('([^"= ]+?)="([^"]+?)"|(data-[^"= ]+?)(?=[" ])', 'mg'); |
| 207 | let nodeValue = node.nodeValue; |
| 208 | const matches = markdownClassesInElementsRegex.exec(nodeValue); |
| 209 | |
| 210 | if (!matches) return false; |
| 211 | |
| 212 | const classes = matches[1]; |
| 213 | nodeValue = |
| 214 | nodeValue.substring(0, matches.index) + |
| 215 | nodeValue.substring(markdownClassesInElementsRegex.lastIndex); |
| 216 | node.nodeValue = nodeValue; |
| 217 | |
| 218 | let matchesClass: RegExpExecArray | null; |
| 219 | while ((matchesClass = markdownClassRegex.exec(classes))) { |
| 220 | if (matchesClass[2]) { |
| 221 | elementTarget.setAttribute(matchesClass[1], matchesClass[2]); |
| 222 | } else { |
| 223 | elementTarget.setAttribute(matchesClass[3], ''); |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | return true; |
| 228 | } |
| 229 | |
| 230 | export function addAttributes( |
| 231 | section: HTMLElement, |
| 232 | element: ChildNode | HTMLElement, |
| 233 | previousElement: Element | null, |
| 234 | separatorElementAttributes: string, |
| 235 | separatorSectionAttributes: string |
| 236 | ) { |
| 237 | if ('childNodes' in element && element.childNodes.length > 0) { |
| 238 | let previousParentElement: Element | null = |
| 239 | element instanceof Element ? element : previousElement; |
| 240 | |
| 241 | for (let index = 0; index < element.childNodes.length; index += 1) { |
| 242 | const childElement = element.childNodes[index]; |
| 243 | |
| 244 | if (index > 0) { |
| 245 | let previousIndex = index - 1; |
| 246 | while (previousIndex >= 0) { |
| 247 | const previousChildElement = element.childNodes[previousIndex]; |
| 248 | if ( |
| 249 | typeof (previousChildElement as Element).setAttribute === 'function' && |
| 250 | (previousChildElement as Element).tagName !== 'BR' |
| 251 | ) { |
| 252 | previousParentElement = previousChildElement as Element; |
| 253 | break; |
| 254 | } |
| 255 | previousIndex -= 1; |
| 256 | } |
| 257 | } |
| 258 | |
| 259 | let parentSection = section; |
| 260 | if ((childElement as Element).nodeName === 'SECTION') { |
| 261 | parentSection = childElement as HTMLElement; |
| 262 | previousParentElement = childElement as Element; |
| 263 | } |
| 264 | |
| 265 | if ( |
| 266 | typeof (childElement as Element).setAttribute === 'function' || |
| 267 | childElement.nodeType === Node.COMMENT_NODE |
| 268 | ) { |
| 269 | addAttributes( |
| 270 | parentSection, |
| 271 | childElement as ChildNode | HTMLElement, |
| 272 | previousParentElement, |
| 273 | separatorElementAttributes, |
| 274 | separatorSectionAttributes |
| 275 | ); |
| 276 | } |
| 277 | } |
| 278 | } |
| 279 | |
| 280 | if (element.nodeType !== Node.COMMENT_NODE) return; |
| 281 | |
| 282 | let targetElement = previousElement; |
| 283 | if (targetElement && (targetElement.tagName === 'UL' || targetElement.tagName === 'OL')) { |
| 284 | targetElement = targetElement.lastElementChild || targetElement; |
| 285 | } |
| 286 | |
| 287 | if (addAttributeInElement(element, targetElement, separatorElementAttributes) === false) { |
| 288 | addAttributeInElement(element, section, separatorSectionAttributes); |
| 289 | } |
| 290 | } |
| 291 | |
| 292 | export function hashString(input: string) { |
| 293 | let hash = 5381; |
| 294 | |
| 295 | for (let index = 0; index < input.length; index += 1) { |
| 296 | hash = (hash * 33) ^ input.charCodeAt(index); |
| 297 | } |
| 298 | |
| 299 | return (hash >>> 0).toString(36); |
| 300 | } |
| 301 | |
| 302 | function serializeSignatureValue(value: unknown): unknown { |
| 303 | if ( |
| 304 | value == null || |
| 305 | typeof value === 'string' || |
| 306 | typeof value === 'number' || |
| 307 | typeof value === 'boolean' |
| 308 | ) { |
| 309 | return value; |
| 310 | } |
| 311 | |
| 312 | if (Array.isArray(value)) { |
| 313 | return value.map(serializeSignatureValue); |
| 314 | } |
| 315 | |
| 316 | if (typeof value === 'object') { |
| 317 | return Object.entries(value as Record<string, unknown>) |
| 318 | .filter(([, entryValue]) => entryValue !== undefined && typeof entryValue !== 'function') |
| 319 | .sort(([a], [b]) => a.localeCompare(b)) |
| 320 | .map(([key, entryValue]) => [key, serializeSignatureValue(entryValue)]); |
| 321 | } |
| 322 | |
| 323 | return String(value); |
| 324 | } |
| 325 | |
| 326 | export function getSectionPropsSignature(attributes: HTMLAttributes<HTMLElement>) { |
| 327 | return JSON.stringify( |
| 328 | Object.entries(attributes) |
| 329 | .filter(([, value]) => value !== undefined && typeof value !== 'function') |
| 330 | .sort(([a], [b]) => a.localeCompare(b)) |
| 331 | .map(([key, value]) => [key, serializeSignatureValue(value)]) |
| 332 | ); |
| 333 | } |
| 334 | |
| 335 | export function getErrorMessage(error: unknown) { |
| 336 | if (error instanceof Error && error.message) return error.message; |
| 337 | return String(error); |
| 338 | } |
| 339 |