bookclub-advr

DSLC Advanced R Book Club
git clone https://git.eamoncaddigan.net/bookclub-advr.git
Log | Files | Refs | README | LICENSE

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   '&': '&amp;',
     23   '<': '&lt;',
     24   '>': '&gt;',
     25   '"': '&quot;',
     26   "'": '&#39;'
     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;