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