| Christophe Dervieux | e1893ae | 2021-10-07 17:09:02 +0200 | [diff] [blame] | 1 | /*! | 
|  | 2 | * The reveal.js markdown plugin. Handles parsing of | 
|  | 3 | * markdown inside of presentations as well as loading | 
|  | 4 | * of external markdown documents. | 
|  | 5 | */ | 
|  | 6 |  | 
|  | 7 | import marked from 'marked' | 
|  | 8 |  | 
|  | 9 | const DEFAULT_SLIDE_SEPARATOR = '\r?\n---\r?\n', | 
|  | 10 | DEFAULT_NOTES_SEPARATOR = 'notes?:', | 
|  | 11 | DEFAULT_ELEMENT_ATTRIBUTES_SEPARATOR = '\\\.element\\\s*?(.+?)$', | 
|  | 12 | DEFAULT_SLIDE_ATTRIBUTES_SEPARATOR = '\\\.slide:\\\s*?(\\\S.+?)$'; | 
|  | 13 |  | 
|  | 14 | const SCRIPT_END_PLACEHOLDER = '__SCRIPT_END__'; | 
|  | 15 |  | 
|  | 16 | const CODE_LINE_NUMBER_REGEX = /\[([\s\d,|-]*)\]/; | 
|  | 17 |  | 
|  | 18 | const HTML_ESCAPE_MAP = { | 
|  | 19 | '&': '&', | 
|  | 20 | '<': '<', | 
|  | 21 | '>': '>', | 
|  | 22 | '"': '"', | 
|  | 23 | "'": ''' | 
|  | 24 | }; | 
|  | 25 |  | 
|  | 26 | const Plugin = () => { | 
|  | 27 |  | 
|  | 28 | // The reveal.js instance this plugin is attached to | 
|  | 29 | let deck; | 
|  | 30 |  | 
|  | 31 | /** | 
|  | 32 | * Retrieves the markdown contents of a slide section | 
|  | 33 | * element. Normalizes leading tabs/whitespace. | 
|  | 34 | */ | 
|  | 35 | function getMarkdownFromSlide( section ) { | 
|  | 36 |  | 
|  | 37 | // look for a <script> or <textarea data-template> wrapper | 
|  | 38 | var template = section.querySelector( '[data-template]' ) || section.querySelector( 'script' ); | 
|  | 39 |  | 
|  | 40 | // strip leading whitespace so it isn't evaluated as code | 
|  | 41 | var text = ( template || section ).textContent; | 
|  | 42 |  | 
|  | 43 | // restore script end tags | 
|  | 44 | text = text.replace( new RegExp( SCRIPT_END_PLACEHOLDER, 'g' ), '</script>' ); | 
|  | 45 |  | 
|  | 46 | var leadingWs = text.match( /^\n?(\s*)/ )[1].length, | 
|  | 47 | leadingTabs = text.match( /^\n?(\t*)/ )[1].length; | 
|  | 48 |  | 
|  | 49 | if( leadingTabs > 0 ) { | 
|  | 50 | text = text.replace( new RegExp('\\n?\\t{' + leadingTabs + '}','g'), '\n' ); | 
|  | 51 | } | 
|  | 52 | else if( leadingWs > 1 ) { | 
|  | 53 | text = text.replace( new RegExp('\\n? {' + leadingWs + '}', 'g'), '\n' ); | 
|  | 54 | } | 
|  | 55 |  | 
|  | 56 | return text; | 
|  | 57 |  | 
|  | 58 | } | 
|  | 59 |  | 
|  | 60 | /** | 
|  | 61 | * Given a markdown slide section element, this will | 
|  | 62 | * return all arguments that aren't related to markdown | 
|  | 63 | * parsing. Used to forward any other user-defined arguments | 
|  | 64 | * to the output markdown slide. | 
|  | 65 | */ | 
|  | 66 | function getForwardedAttributes( section ) { | 
|  | 67 |  | 
|  | 68 | var attributes = section.attributes; | 
|  | 69 | var result = []; | 
|  | 70 |  | 
|  | 71 | for( var i = 0, len = attributes.length; i < len; i++ ) { | 
|  | 72 | var name = attributes[i].name, | 
|  | 73 | value = attributes[i].value; | 
|  | 74 |  | 
|  | 75 | // disregard attributes that are used for markdown loading/parsing | 
|  | 76 | if( /data\-(markdown|separator|vertical|notes)/gi.test( name ) ) continue; | 
|  | 77 |  | 
|  | 78 | if( value ) { | 
|  | 79 | result.push( name + '="' + value + '"' ); | 
|  | 80 | } | 
|  | 81 | else { | 
|  | 82 | result.push( name ); | 
|  | 83 | } | 
|  | 84 | } | 
|  | 85 |  | 
|  | 86 | return result.join( ' ' ); | 
|  | 87 |  | 
|  | 88 | } | 
|  | 89 |  | 
|  | 90 | /** | 
|  | 91 | * Inspects the given options and fills out default | 
|  | 92 | * values for what's not defined. | 
|  | 93 | */ | 
|  | 94 | function getSlidifyOptions( options ) { | 
|  | 95 |  | 
|  | 96 | options = options || {}; | 
|  | 97 | options.separator = options.separator || DEFAULT_SLIDE_SEPARATOR; | 
|  | 98 | options.notesSeparator = options.notesSeparator || DEFAULT_NOTES_SEPARATOR; | 
|  | 99 | options.attributes = options.attributes || ''; | 
|  | 100 |  | 
|  | 101 | return options; | 
|  | 102 |  | 
|  | 103 | } | 
|  | 104 |  | 
|  | 105 | /** | 
|  | 106 | * Helper function for constructing a markdown slide. | 
|  | 107 | */ | 
|  | 108 | function createMarkdownSlide( content, options ) { | 
|  | 109 |  | 
|  | 110 | options = getSlidifyOptions( options ); | 
|  | 111 |  | 
|  | 112 | var notesMatch = content.split( new RegExp( options.notesSeparator, 'mgi' ) ); | 
|  | 113 |  | 
|  | 114 | if( notesMatch.length === 2 ) { | 
|  | 115 | content = notesMatch[0] + '<aside class="notes">' + marked(notesMatch[1].trim()) + '</aside>'; | 
|  | 116 | } | 
|  | 117 |  | 
|  | 118 | // prevent script end tags in the content from interfering | 
|  | 119 | // with parsing | 
|  | 120 | content = content.replace( /<\/script>/g, SCRIPT_END_PLACEHOLDER ); | 
|  | 121 |  | 
|  | 122 | return '<script type="text/template">' + content + '</script>'; | 
|  | 123 |  | 
|  | 124 | } | 
|  | 125 |  | 
|  | 126 | /** | 
|  | 127 | * Parses a data string into multiple slides based | 
|  | 128 | * on the passed in separator arguments. | 
|  | 129 | */ | 
|  | 130 | function slidify( markdown, options ) { | 
|  | 131 |  | 
|  | 132 | options = getSlidifyOptions( options ); | 
|  | 133 |  | 
|  | 134 | var separatorRegex = new RegExp( options.separator + ( options.verticalSeparator ? '|' + options.verticalSeparator : '' ), 'mg' ), | 
|  | 135 | horizontalSeparatorRegex = new RegExp( options.separator ); | 
|  | 136 |  | 
|  | 137 | var matches, | 
|  | 138 | lastIndex = 0, | 
|  | 139 | isHorizontal, | 
|  | 140 | wasHorizontal = true, | 
|  | 141 | content, | 
|  | 142 | sectionStack = []; | 
|  | 143 |  | 
|  | 144 | // iterate until all blocks between separators are stacked up | 
|  | 145 | while( matches = separatorRegex.exec( markdown ) ) { | 
|  | 146 | var notes = null; | 
|  | 147 |  | 
|  | 148 | // determine direction (horizontal by default) | 
|  | 149 | isHorizontal = horizontalSeparatorRegex.test( matches[0] ); | 
|  | 150 |  | 
|  | 151 | if( !isHorizontal && wasHorizontal ) { | 
|  | 152 | // create vertical stack | 
|  | 153 | sectionStack.push( [] ); | 
|  | 154 | } | 
|  | 155 |  | 
|  | 156 | // pluck slide content from markdown input | 
|  | 157 | content = markdown.substring( lastIndex, matches.index ); | 
|  | 158 |  | 
|  | 159 | if( isHorizontal && wasHorizontal ) { | 
|  | 160 | // add to horizontal stack | 
|  | 161 | sectionStack.push( content ); | 
|  | 162 | } | 
|  | 163 | else { | 
|  | 164 | // add to vertical stack | 
|  | 165 | sectionStack[sectionStack.length-1].push( content ); | 
|  | 166 | } | 
|  | 167 |  | 
|  | 168 | lastIndex = separatorRegex.lastIndex; | 
|  | 169 | wasHorizontal = isHorizontal; | 
|  | 170 | } | 
|  | 171 |  | 
|  | 172 | // add the remaining slide | 
|  | 173 | ( wasHorizontal ? sectionStack : sectionStack[sectionStack.length-1] ).push( markdown.substring( lastIndex ) ); | 
|  | 174 |  | 
|  | 175 | var markdownSections = ''; | 
|  | 176 |  | 
|  | 177 | // flatten the hierarchical stack, and insert <section data-markdown> tags | 
|  | 178 | for( var i = 0, len = sectionStack.length; i < len; i++ ) { | 
|  | 179 | // vertical | 
|  | 180 | if( sectionStack[i] instanceof Array ) { | 
|  | 181 | markdownSections += '<section '+ options.attributes +'>'; | 
|  | 182 |  | 
|  | 183 | sectionStack[i].forEach( function( child ) { | 
|  | 184 | markdownSections += '<section data-markdown>' + createMarkdownSlide( child, options ) + '</section>'; | 
|  | 185 | } ); | 
|  | 186 |  | 
|  | 187 | markdownSections += '</section>'; | 
|  | 188 | } | 
|  | 189 | else { | 
|  | 190 | markdownSections += '<section '+ options.attributes +' data-markdown>' + createMarkdownSlide( sectionStack[i], options ) + '</section>'; | 
|  | 191 | } | 
|  | 192 | } | 
|  | 193 |  | 
|  | 194 | return markdownSections; | 
|  | 195 |  | 
|  | 196 | } | 
|  | 197 |  | 
|  | 198 | /** | 
|  | 199 | * Parses any current data-markdown slides, splits | 
|  | 200 | * multi-slide markdown into separate sections and | 
|  | 201 | * handles loading of external markdown. | 
|  | 202 | */ | 
|  | 203 | function processSlides( scope ) { | 
|  | 204 |  | 
|  | 205 | return new Promise( function( resolve ) { | 
|  | 206 |  | 
|  | 207 | var externalPromises = []; | 
|  | 208 |  | 
|  | 209 | [].slice.call( scope.querySelectorAll( 'section[data-markdown]:not([data-markdown-parsed])') ).forEach( function( section, i ) { | 
|  | 210 |  | 
|  | 211 | if( section.getAttribute( 'data-markdown' ).length ) { | 
|  | 212 |  | 
|  | 213 | externalPromises.push( loadExternalMarkdown( section ).then( | 
|  | 214 |  | 
|  | 215 | // Finished loading external file | 
|  | 216 | function( xhr, url ) { | 
|  | 217 | section.outerHTML = slidify( xhr.responseText, { | 
|  | 218 | separator: section.getAttribute( 'data-separator' ), | 
|  | 219 | verticalSeparator: section.getAttribute( 'data-separator-vertical' ), | 
|  | 220 | notesSeparator: section.getAttribute( 'data-separator-notes' ), | 
|  | 221 | attributes: getForwardedAttributes( section ) | 
|  | 222 | }); | 
|  | 223 | }, | 
|  | 224 |  | 
|  | 225 | // Failed to load markdown | 
|  | 226 | function( xhr, url ) { | 
|  | 227 | section.outerHTML = '<section data-state="alert">' + | 
|  | 228 | 'ERROR: The attempt to fetch ' + url + ' failed with HTTP status ' + xhr.status + '.' + | 
|  | 229 | 'Check your browser\'s JavaScript console for more details.' + | 
|  | 230 | '<p>Remember that you need to serve the presentation HTML from a HTTP server.</p>' + | 
|  | 231 | '</section>'; | 
|  | 232 | } | 
|  | 233 |  | 
|  | 234 | ) ); | 
|  | 235 |  | 
|  | 236 | } | 
|  | 237 | else { | 
|  | 238 |  | 
|  | 239 | section.outerHTML = slidify( getMarkdownFromSlide( section ), { | 
|  | 240 | separator: section.getAttribute( 'data-separator' ), | 
|  | 241 | verticalSeparator: section.getAttribute( 'data-separator-vertical' ), | 
|  | 242 | notesSeparator: section.getAttribute( 'data-separator-notes' ), | 
|  | 243 | attributes: getForwardedAttributes( section ) | 
|  | 244 | }); | 
|  | 245 |  | 
|  | 246 | } | 
|  | 247 |  | 
|  | 248 | }); | 
|  | 249 |  | 
|  | 250 | Promise.all( externalPromises ).then( resolve ); | 
|  | 251 |  | 
|  | 252 | } ); | 
|  | 253 |  | 
|  | 254 | } | 
|  | 255 |  | 
|  | 256 | function loadExternalMarkdown( section ) { | 
|  | 257 |  | 
|  | 258 | return new Promise( function( resolve, reject ) { | 
|  | 259 |  | 
|  | 260 | var xhr = new XMLHttpRequest(), | 
|  | 261 | url = section.getAttribute( 'data-markdown' ); | 
|  | 262 |  | 
|  | 263 | var datacharset = section.getAttribute( 'data-charset' ); | 
|  | 264 |  | 
|  | 265 | // see https://developer.mozilla.org/en-US/docs/Web/API/element.getAttribute#Notes | 
|  | 266 | if( datacharset != null && datacharset != '' ) { | 
|  | 267 | xhr.overrideMimeType( 'text/html; charset=' + datacharset ); | 
|  | 268 | } | 
|  | 269 |  | 
|  | 270 | xhr.onreadystatechange = function( section, xhr ) { | 
|  | 271 | if( xhr.readyState === 4 ) { | 
|  | 272 | // file protocol yields status code 0 (useful for local debug, mobile applications etc.) | 
|  | 273 | if ( ( xhr.status >= 200 && xhr.status < 300 ) || xhr.status === 0 ) { | 
|  | 274 |  | 
|  | 275 | resolve( xhr, url ); | 
|  | 276 |  | 
|  | 277 | } | 
|  | 278 | else { | 
|  | 279 |  | 
|  | 280 | reject( xhr, url ); | 
|  | 281 |  | 
|  | 282 | } | 
|  | 283 | } | 
|  | 284 | }.bind( this, section, xhr ); | 
|  | 285 |  | 
|  | 286 | xhr.open( 'GET', url, true ); | 
|  | 287 |  | 
|  | 288 | try { | 
|  | 289 | xhr.send(); | 
|  | 290 | } | 
|  | 291 | catch ( e ) { | 
|  | 292 | console.warn( 'Failed to get the Markdown file ' + url + '. Make sure that the presentation and the file are served by a HTTP server and the file can be found there. ' + e ); | 
|  | 293 | resolve( xhr, url ); | 
|  | 294 | } | 
|  | 295 |  | 
|  | 296 | } ); | 
|  | 297 |  | 
|  | 298 | } | 
|  | 299 |  | 
|  | 300 | /** | 
|  | 301 | * Check if a node value has the attributes pattern. | 
|  | 302 | * If yes, extract it and add that value as one or several attributes | 
|  | 303 | * to the target element. | 
|  | 304 | * | 
|  | 305 | * You need Cache Killer on Chrome to see the effect on any FOM transformation | 
|  | 306 | * directly on refresh (F5) | 
|  | 307 | * http://stackoverflow.com/questions/5690269/disabling-chrome-cache-for-website-development/7000899#answer-11786277 | 
|  | 308 | */ | 
|  | 309 | function addAttributeInElement( node, elementTarget, separator ) { | 
|  | 310 |  | 
|  | 311 | var mardownClassesInElementsRegex = new RegExp( separator, 'mg' ); | 
|  | 312 | var mardownClassRegex = new RegExp( "([^\"= ]+?)=\"([^\"]+?)\"|(data-[^\"= ]+?)(?=[\" ])", 'mg' ); | 
|  | 313 | var nodeValue = node.nodeValue; | 
|  | 314 | var matches, | 
|  | 315 | matchesClass; | 
|  | 316 | if( matches = mardownClassesInElementsRegex.exec( nodeValue ) ) { | 
|  | 317 |  | 
|  | 318 | var classes = matches[1]; | 
|  | 319 | nodeValue = nodeValue.substring( 0, matches.index ) + nodeValue.substring( mardownClassesInElementsRegex.lastIndex ); | 
|  | 320 | node.nodeValue = nodeValue; | 
|  | 321 | while( matchesClass = mardownClassRegex.exec( classes ) ) { | 
|  | 322 | if( matchesClass[2] ) { | 
|  | 323 | elementTarget.setAttribute( matchesClass[1], matchesClass[2] ); | 
|  | 324 | } else { | 
|  | 325 | elementTarget.setAttribute( matchesClass[3], "" ); | 
|  | 326 | } | 
|  | 327 | } | 
|  | 328 | return true; | 
|  | 329 | } | 
|  | 330 | return false; | 
|  | 331 | } | 
|  | 332 |  | 
|  | 333 | /** | 
|  | 334 | * Add attributes to the parent element of a text node, | 
|  | 335 | * or the element of an attribute node. | 
|  | 336 | */ | 
|  | 337 | function addAttributes( section, element, previousElement, separatorElementAttributes, separatorSectionAttributes ) { | 
|  | 338 |  | 
|  | 339 | if ( element != null && element.childNodes != undefined && element.childNodes.length > 0 ) { | 
|  | 340 | var previousParentElement = element; | 
|  | 341 | for( var i = 0; i < element.childNodes.length; i++ ) { | 
|  | 342 | var childElement = element.childNodes[i]; | 
|  | 343 | if ( i > 0 ) { | 
|  | 344 | var j = i - 1; | 
|  | 345 | while ( j >= 0 ) { | 
|  | 346 | var aPreviousChildElement = element.childNodes[j]; | 
|  | 347 | if ( typeof aPreviousChildElement.setAttribute == 'function' && aPreviousChildElement.tagName != "BR" ) { | 
|  | 348 | previousParentElement = aPreviousChildElement; | 
|  | 349 | break; | 
|  | 350 | } | 
|  | 351 | j = j - 1; | 
|  | 352 | } | 
|  | 353 | } | 
|  | 354 | var parentSection = section; | 
|  | 355 | if( childElement.nodeName ==  "section" ) { | 
|  | 356 | parentSection = childElement ; | 
|  | 357 | previousParentElement = childElement ; | 
|  | 358 | } | 
|  | 359 | if ( typeof childElement.setAttribute == 'function' || childElement.nodeType == Node.COMMENT_NODE ) { | 
|  | 360 | addAttributes( parentSection, childElement, previousParentElement, separatorElementAttributes, separatorSectionAttributes ); | 
|  | 361 | } | 
|  | 362 | } | 
|  | 363 | } | 
|  | 364 |  | 
|  | 365 | if ( element.nodeType == Node.COMMENT_NODE ) { | 
|  | 366 | if ( addAttributeInElement( element, previousElement, separatorElementAttributes ) == false ) { | 
|  | 367 | addAttributeInElement( element, section, separatorSectionAttributes ); | 
|  | 368 | } | 
|  | 369 | } | 
|  | 370 | } | 
|  | 371 |  | 
|  | 372 | /** | 
|  | 373 | * Converts any current data-markdown slides in the | 
|  | 374 | * DOM to HTML. | 
|  | 375 | */ | 
|  | 376 | function convertSlides() { | 
|  | 377 |  | 
|  | 378 | var sections = deck.getRevealElement().querySelectorAll( '[data-markdown]:not([data-markdown-parsed])'); | 
|  | 379 |  | 
|  | 380 | [].slice.call( sections ).forEach( function( section ) { | 
|  | 381 |  | 
|  | 382 | section.setAttribute( 'data-markdown-parsed', true ) | 
|  | 383 |  | 
|  | 384 | var notes = section.querySelector( 'aside.notes' ); | 
|  | 385 | var markdown = getMarkdownFromSlide( section ); | 
|  | 386 |  | 
|  | 387 | section.innerHTML = marked( markdown ); | 
|  | 388 | addAttributes( 	section, section, null, section.getAttribute( 'data-element-attributes' ) || | 
|  | 389 | section.parentNode.getAttribute( 'data-element-attributes' ) || | 
|  | 390 | DEFAULT_ELEMENT_ATTRIBUTES_SEPARATOR, | 
|  | 391 | section.getAttribute( 'data-attributes' ) || | 
|  | 392 | section.parentNode.getAttribute( 'data-attributes' ) || | 
|  | 393 | DEFAULT_SLIDE_ATTRIBUTES_SEPARATOR); | 
|  | 394 |  | 
|  | 395 | // If there were notes, we need to re-add them after | 
|  | 396 | // having overwritten the section's HTML | 
|  | 397 | if( notes ) { | 
|  | 398 | section.appendChild( notes ); | 
|  | 399 | } | 
|  | 400 |  | 
|  | 401 | } ); | 
|  | 402 |  | 
|  | 403 | return Promise.resolve(); | 
|  | 404 |  | 
|  | 405 | } | 
|  | 406 |  | 
|  | 407 | function escapeForHTML( input ) { | 
|  | 408 |  | 
|  | 409 | return input.replace( /([&<>'"])/g, char => HTML_ESCAPE_MAP[char] ); | 
|  | 410 |  | 
|  | 411 | } | 
|  | 412 |  | 
|  | 413 | return { | 
|  | 414 | id: 'markdown', | 
|  | 415 |  | 
|  | 416 | /** | 
|  | 417 | * Starts processing and converting Markdown within the | 
|  | 418 | * current reveal.js deck. | 
|  | 419 | */ | 
|  | 420 | init: function( reveal ) { | 
|  | 421 |  | 
|  | 422 | deck = reveal; | 
|  | 423 |  | 
|  | 424 | let { renderer, animateLists, ...markedOptions } = deck.getConfig().markdown || {}; | 
|  | 425 |  | 
|  | 426 | if( !renderer ) { | 
|  | 427 | renderer = new marked.Renderer(); | 
|  | 428 |  | 
|  | 429 | renderer.code = ( code, language ) => { | 
|  | 430 |  | 
|  | 431 | // Off by default | 
|  | 432 | let lineNumbers = ''; | 
|  | 433 |  | 
|  | 434 | // Users can opt in to show line numbers and highlight | 
|  | 435 | // specific lines. | 
|  | 436 | // ```javascript []        show line numbers | 
|  | 437 | // ```javascript [1,4-8]   highlights lines 1 and 4-8 | 
|  | 438 | if( CODE_LINE_NUMBER_REGEX.test( language ) ) { | 
|  | 439 | lineNumbers = language.match( CODE_LINE_NUMBER_REGEX )[1].trim(); | 
|  | 440 | lineNumbers = `data-line-numbers="${lineNumbers}"`; | 
|  | 441 | language = language.replace( CODE_LINE_NUMBER_REGEX, '' ).trim(); | 
|  | 442 | } | 
|  | 443 |  | 
|  | 444 | // Escape before this gets injected into the DOM to | 
|  | 445 | // avoid having the HTML parser alter our code before | 
|  | 446 | // highlight.js is able to read it | 
|  | 447 | code = escapeForHTML( code ); | 
|  | 448 |  | 
|  | 449 | return `<pre><code ${lineNumbers} class="${language}">${code}</code></pre>`; | 
|  | 450 | }; | 
|  | 451 | } | 
|  | 452 |  | 
|  | 453 | if( animateLists === true ) { | 
|  | 454 | renderer.listitem = text => `<li class="fragment">${text}</li>`; | 
|  | 455 | } | 
|  | 456 |  | 
|  | 457 | marked.setOptions( { | 
|  | 458 | renderer, | 
|  | 459 | ...markedOptions | 
|  | 460 | } ); | 
|  | 461 |  | 
|  | 462 | return processSlides( deck.getRevealElement() ).then( convertSlides ); | 
|  | 463 |  | 
|  | 464 | }, | 
|  | 465 |  | 
|  | 466 | // TODO: Do these belong in the API? | 
|  | 467 | processSlides: processSlides, | 
|  | 468 | convertSlides: convertSlides, | 
|  | 469 | slidify: slidify, | 
|  | 470 | marked: marked | 
|  | 471 | } | 
|  | 472 |  | 
|  | 473 | }; | 
|  | 474 |  | 
|  | 475 | export default Plugin; |