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