diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..2a95ce1 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,37 @@ +{ + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module", + "requireConfigFile": false + }, + "env": { + "browser": true, + "es6": true, + "node": true, + "jest": true + }, + "rules": { + "no-var": "error", + "prefer-const": "warn", + "no-console": "off", + "no-debugger": "warn", + "no-undef": "error", + "no-unused-vars": "warn", + "quotes": ["warn", "single"], + "semi": ["error", "never"], + "new-cap": 2, + "no-caller": 2, + "dot-notation": 0, + "no-eq-null": 2, + "no-unused-expressions": 0, + "curly": 0, + "eqeqeq": 2, + "wrap-iife": [2, "any"], + "no-use-before-define": [ + 2, + { + "functions": false + } + ] + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13394bd..ff0da56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,21 +1,28 @@ name: CI on: - pull_request: - branches: - - main push: branches: - main + pull_request: jobs: unit-test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup NodeJS + uses: actions/setup-node@v3 with: node-version: 18 - - run: | - npm install - npm run test:unit + + - name: Install dependencies + run: npm install --frozen-lockfile + + - name: Lint + run: npm run lint + + - name: Unit Test + run: npm run test:unit diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..f1cc42d --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "singleQuote": true, + "trailingComma": "es5", + "printWidth": 120, + "tabWidth": 4, + "semi": false, + "bracketSpacing": true, + "proseWrap": "always", + "arrowParens": "always", + "htmlWhitespaceSensitivity": "css" +} diff --git a/babel.config.js b/babel.config.js index c838e5e..a3d0fd6 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,4 +1,4 @@ module.exports = { presets: ['@babel/preset-env'], - plugins: ['babel-plugin-transform-import-meta'] + plugins: ['babel-plugin-transform-import-meta'], } diff --git a/gulpfile.js b/gulpfile.js index 7bd339a..e2cd8c5 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -1,61 +1,72 @@ const gulp = require('gulp') -const {rollup} = require('rollup') +const eslint = require('gulp-eslint') +const prettier = require('gulp-prettier') +const { rollup } = require('rollup') const terser = require('@rollup/plugin-terser') const babel = require('@rollup/plugin-babel').default const commonjs = require('@rollup/plugin-commonjs') const resolve = require('@rollup/plugin-node-resolve').default -let cache = {} +const cache = {} const babelConfig = { babelHelpers: 'bundled', ignore: ['node_modules'], compact: false, extensions: ['.js', '.html'], - plugins: [ - 'transform-html-import-to-string' + plugins: ['transform-html-import-to-string'], + presets: [ + [ + '@babel/preset-env', + { + corejs: 3, + useBuiltIns: 'usage', + modules: false, + }, + ], ], - presets: [[ - '@babel/preset-env', - { - corejs: 3, - useBuiltIns: 'usage', - modules: false - } - ]], - configFile: false + configFile: false, } +gulp.task('lint', () => + gulp.src(['./**/*.js', '!node_modules/**', '!plugin/awesoMD/awesoMD*.js']).pipe(eslint()).pipe(eslint.format()) +) + +gulp.task('format', () => + gulp.src(['./**/*.js', '!node_modules/**', '!plugin/awesoMD/awesoMD*.js']).pipe(prettier()).pipe(gulp.dest('.')) +) gulp.task('build-plugins', () => { - return Promise.all([ - { name: 'RevealAwesoMD', input: './plugin/awesoMD/plugin.js', output: './plugin/awesoMD/awesoMD' }, - ].map( plugin => { - return rollup({ - cache: cache[plugin.input], - input: plugin.input, - plugins: [ - resolve(), - commonjs(), - babel({ - ...babelConfig, - ignore: ["node_modules"], - }), - terser() - ] - }).then( bundle => { - cache[plugin.input] = bundle.cache; - bundle.write({ - file: plugin.output + '.esm.js', - name: plugin.name, - format: 'es' - }) + return Promise.all( + [{ name: 'RevealAwesoMD', input: './plugin/awesoMD/plugin.js', output: './plugin/awesoMD/awesoMD' }].map( + (plugin) => { + return rollup({ + cache: cache[plugin.input], + input: plugin.input, + plugins: [ + resolve(), + commonjs(), + babel({ + ...babelConfig, + ignore: ['node_modules'], + }), + terser(), + ], + }).then((bundle) => { + cache[plugin.input] = bundle.cache + bundle.write({ + file: plugin.output + '.esm.js', + name: plugin.name, + format: 'es', + }) - bundle.write({ - file: plugin.output + '.js', - name: plugin.name, - format: 'umd' + bundle.write({ + file: plugin.output + '.js', + name: plugin.name, + format: 'umd', + }) }) - }); - } )); + } + ) + ) }) diff --git a/jest.config.js b/jest.config.js index f98980f..dce66a6 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,10 +1,10 @@ module.exports = { - testEnvironment: "node", - testMatch: ["**/__tests__/**/*.js", "**/?(*.)+(spec|test).js"], - moduleFileExtensions: ["js", "json", "jsx", "node"], + testEnvironment: 'node', + testMatch: ['**/__tests__/**/*.js', '**/?(*.)+(spec|test).js'], + moduleFileExtensions: ['js', 'json', 'jsx', 'node'], collectCoverage: true, verbose: true, transform: { - "\\.[j]sx?$": "babel-jest", - } -}; + '\\.[j]sx?$': 'babel-jest', + }, +} diff --git a/package.json b/package.json index ad38a34..5cadea7 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,9 @@ "description": "Revealjs plugin to support markdown with metadata and templates", "scripts": { "build": "gulp build-plugins", - "test:unit": "jest" + "test:unit": "jest", + "lint": "gulp lint", + "lint:fix": "gulp format" }, "repository": { "type": "git", @@ -30,6 +32,8 @@ "core-js": "^3.36.1", "front-matter": "^4.0.2", "gulp": "^5.0.0", + "gulp-eslint": "^6.0.0", + "gulp-prettier": "^5.0.0", "jest": "^29.7.0", "js-yaml": "^4.1.0", "marked": "^4.3.0", diff --git a/plugin/awesoMD/plugin.js b/plugin/awesoMD/plugin.js index 72be7e6..52b98be 100644 --- a/plugin/awesoMD/plugin.js +++ b/plugin/awesoMD/plugin.js @@ -4,652 +4,652 @@ * of external markdown documents. */ -import { marked } from 'marked'; -import yaml from 'js-yaml'; -import Mustache from 'mustache'; -import fm from 'front-matter'; +import { marked } from 'marked' +import yaml from 'js-yaml' +import Mustache from 'mustache' +import fm from 'front-matter' const DEFAULT_SLIDE_SEPARATOR = '\r?\n---\r?\n', - DEFAULT_VERTICAL_SEPARATOR = null, - DEFAULT_NOTES_SEPARATOR = '^\s*notes?:', - DEFAULT_ELEMENT_ATTRIBUTES_SEPARATOR = '\\\.element\\\s*?(.+?)$', - DEFAULT_SLIDE_ATTRIBUTES_SEPARATOR = '\\\.slide:\\\s*?(\\\S.+?)$'; + DEFAULT_VERTICAL_SEPARATOR = null, + DEFAULT_NOTES_SEPARATOR = '^s*notes?:', + DEFAULT_ELEMENT_ATTRIBUTES_SEPARATOR = '\\.element\\s*?(.+?)$', + DEFAULT_SLIDE_ATTRIBUTES_SEPARATOR = '\\.slide:\\s*?(\\S.+?)$' -const SCRIPT_END_PLACEHOLDER = '__SCRIPT_END__'; +const SCRIPT_END_PLACEHOLDER = '__SCRIPT_END__' // match an optional line number offset and highlight line numbers // [] or [: ] -const CODE_LINE_NUMBER_REGEX = /\[\s*((\d*):)?\s*([\s\d,|-]*)\]/; +const CODE_LINE_NUMBER_REGEX = /\[\s*((\d*):)?\s*([\s\d,|-]*)\]/ const HTML_ESCAPE_MAP = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''' -}; + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +} const yamlRegex = /```(yaml|yml)\n([\s\S]*?)```(\n[\s\S]*)?/g const headingWithMetadataRegex = /^#+\s.*::\w+:\w+.*$/m const metadataRegex = /::(\w+):([^::\n]*)/g const Plugin = () => { - - // The reveal.js instance this plugin is attached to - let deck; - - /** - * Retrieves the markdown contents of a slide section - * element. Normalizes leading tabs/whitespace. - */ - function getMarkdownFromSlide( section ) { - - // look for a ' ); - - const leadingWs = text.match( /^\n?(\s*)/ )[1].length, - leadingTabs = text.match( /^\n?(\t*)/ )[1].length; - - if( leadingTabs > 0 ) { - text = text.replace( new RegExp('\\n?\\t{' + leadingTabs + '}(.*)','g'), function(m, p1) { return '\n' + p1 ; } ); - } - else if( leadingWs > 1 ) { - text = text.replace( new RegExp('\\n? {' + leadingWs + '}(.*)', 'g'), function(m, p1) { return '\n' + p1 ; } ); - } - - return text; - - } - - /** - * Given a markdown slide section element, this will - * return all arguments that aren't related to markdown - * parsing. Used to forward any other user-defined arguments - * to the output markdown slide. - */ - function getForwardedAttributes( section ) { - - const attributes = section.attributes; - const result = []; - - for( let i = 0, len = attributes.length; i < len; i++ ) { - const name = attributes[i].name, - value = attributes[i].value; - - // disregard attributes that are used for markdown loading/parsing - if( /data\-(markdown|separator|vertical|notes)/gi.test( name ) ) continue; - - if( value ) { - result.push( name + '="' + value + '"' ); - } - else { - result.push( name ); - } - } - - return result.join( ' ' ); - - } - - /** - * Inspects the given options and fills out default - * values for what's not defined. - */ - function getSlidifyOptions( options ) { - const markdownConfig = deck?.getConfig?.().markdown; - - options = options || {}; - options.separator = options.separator || markdownConfig?.separator || DEFAULT_SLIDE_SEPARATOR; - options.verticalSeparator = options.verticalSeparator || markdownConfig?.verticalSeparator || DEFAULT_VERTICAL_SEPARATOR; - options.notesSeparator = options.notesSeparator || markdownConfig?.notesSeparator || DEFAULT_NOTES_SEPARATOR; - options.attributes = options.attributes || ''; - - return options; - - } - - /** - * Helper function for constructing a markdown slide. - */ - function createMarkdownSlide( content, options ) { - - options = getSlidifyOptions( options ); - - const notesMatch = content.split( new RegExp( options.notesSeparator, 'mgi' ) ); - - if( notesMatch.length === 2 ) { - content = notesMatch[0] + ''; - } - - // prevent script end tags in the content from interfering - // with parsing - content = content.replace( /<\/script>/g, SCRIPT_END_PLACEHOLDER ); - - // render the template with the content only if there is metadata - if (options.metadata){ - content = renderTemplate(content, options) - } - - return ''; - - } - - /** - * Parses a data string into multiple slides based - * on the passed in separator arguments. - */ - function slidify( markdown, options ) { - - options = getSlidifyOptions( options ); - - const separatorRegex = new RegExp( options.separator + ( options.verticalSeparator ? '|' + options.verticalSeparator : '' ), 'mg' ), - horizontalSeparatorRegex = new RegExp( options.separator ); - - let matches, - lastIndex = 0, - isHorizontal, - wasHorizontal = true, - content, - sectionStack = []; - - // separates default metadata from the markdown file - [ markdown, options ] = parseFrontMatter(markdown, options) - - // iterate until all blocks between separators are stacked up - while( matches = separatorRegex.exec( markdown ) ) { - const notes = null; - - // determine direction (horizontal by default) - isHorizontal = horizontalSeparatorRegex.test( matches[0] ); - - if( !isHorizontal && wasHorizontal ) { - // create vertical stack - sectionStack.push( [] ); - } - - // pluck slide content from markdown input - content = markdown.substring( lastIndex, matches.index ); - - if( isHorizontal && wasHorizontal ) { - // add to horizontal stack - sectionStack.push( content ); - } - else { - // add to vertical stack - sectionStack[sectionStack.length-1].push( content ); - } - - lastIndex = separatorRegex.lastIndex; - wasHorizontal = isHorizontal; - } - - // add the remaining slide - ( wasHorizontal ? sectionStack : sectionStack[sectionStack.length-1] ).push( markdown.substring( lastIndex ) ); - - let markdownSections = ''; - - // flatten the hierarchical stack, and insert
tags - for( let i = 0, len = sectionStack.length; i < len; i++ ) { - // slideOptions is created to avoid mutating the original options object with default metadata - let slideOptions = {...options} - - // vertical - if( sectionStack[i] instanceof Array ) { - markdownSections += '
'; - - sectionStack[i].forEach( function( child ) { - [content, slideOptions] = separateInlineMetadataAndMarkdown(child, slideOptions) - markdownSections += '
' + createMarkdownSlide( content, slideOptions ) + '
'; - } ); - - markdownSections += '
'; - } - else { - [content, slideOptions] = separateInlineMetadataAndMarkdown(sectionStack[i], slideOptions) - markdownSections += '
' + createMarkdownSlide( content, slideOptions ) + '
'; - } - } - - return markdownSections; - - } - - /** - * Parses any current data-markdown slides, splits - * multi-slide markdown into separate sections and - * handles loading of external markdown. - */ - function processSlides( scope ) { - - return new Promise( function( resolve ) { - - const externalPromises = []; - - [].slice.call( scope.querySelectorAll( 'section[data-markdown]:not([data-markdown-parsed])') ).forEach( function( section, i ) { - - if( section.getAttribute( 'data-markdown' ).length ) { - - externalPromises.push( loadExternalMarkdown( section ).then( - - // Finished loading external file - function( xhr, url ) { - section.outerHTML = slidify( xhr.responseText, { - separator: section.getAttribute( 'data-separator' ), - verticalSeparator: section.getAttribute( 'data-separator-vertical' ), - notesSeparator: section.getAttribute( 'data-separator-notes' ), - attributes: getForwardedAttributes( section ) - }); - }, - - // Failed to load markdown - function( xhr, url ) { - section.outerHTML = '
' + - 'ERROR: The attempt to fetch ' + url + ' failed with HTTP status ' + xhr.status + '.' + - 'Check your browser\'s JavaScript console for more details.' + - '

Remember that you need to serve the presentation HTML from a HTTP server.

' + - '
'; - } - - ) ); - - } - else { - - section.outerHTML = slidify( getMarkdownFromSlide( section ), { - separator: section.getAttribute( 'data-separator' ), - verticalSeparator: section.getAttribute( 'data-separator-vertical' ), - notesSeparator: section.getAttribute( 'data-separator-notes' ), - attributes: getForwardedAttributes( section ) - }); - - } - - }); - - Promise.all( externalPromises ).then( resolve ); - - } ); - - } - - function loadExternalMarkdown( section ) { - - return new Promise( function( resolve, reject ) { - - const xhr = new XMLHttpRequest(), - url = section.getAttribute( 'data-markdown' ); - - const datacharset = section.getAttribute( 'data-charset' ); - - // see https://developer.mozilla.org/en-US/docs/Web/API/element.getAttribute#Notes - if( datacharset !== null && datacharset !== '' ) { - xhr.overrideMimeType( 'text/html; charset=' + datacharset ); - } - - xhr.onreadystatechange = function( section, xhr ) { - if( xhr.readyState === 4 ) { - // file protocol yields status code 0 (useful for local debug, mobile applications etc.) - if ( ( xhr.status >= 200 && xhr.status < 300 ) || xhr.status === 0 ) { - - resolve( xhr, url ); - - } - else { - - reject( xhr, url ); - - } - } - }.bind( this, section, xhr ); - - xhr.open( 'GET', url, true ); - - try { - xhr.send(); - } - catch ( e ) { - 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 ); - resolve( xhr, url ); - } - - } ); - - } - - /** - * Check if a node value has the attributes pattern. - * If yes, extract it and add that value as one or several attributes - * to the target element. - * - * You need Cache Killer on Chrome to see the effect on any FOM transformation - * directly on refresh (F5) - * http://stackoverflow.com/questions/5690269/disabling-chrome-cache-for-website-development/7000899#answer-11786277 - */ - function addAttributeInElement( node, elementTarget, separator ) { - - const markdownClassesInElementsRegex = new RegExp( separator, 'mg' ); - const markdownClassRegex = new RegExp( "([^\"= ]+?)=\"([^\"]+?)\"|(data-[^\"= ]+?)(?=[\" ])", 'mg' ); - let nodeValue = node.nodeValue; - let matches, - matchesClass; - if( matches = markdownClassesInElementsRegex.exec( nodeValue ) ) { - - const classes = matches[1]; - nodeValue = nodeValue.substring( 0, matches.index ) + nodeValue.substring( markdownClassesInElementsRegex.lastIndex ); - node.nodeValue = nodeValue; - while( matchesClass = markdownClassRegex.exec( classes ) ) { - if( matchesClass[2] ) { - elementTarget.setAttribute( matchesClass[1], matchesClass[2] ); - } else { - elementTarget.setAttribute( matchesClass[3], "" ); - } - } - return true; - } - return false; - } - - /** - * Add attributes to the parent element of a text node, - * or the element of an attribute node. - */ - function addAttributes( section, element, previousElement, separatorElementAttributes, separatorSectionAttributes ) { - - if ( element !== null && element.childNodes !== undefined && element.childNodes.length > 0 ) { - let previousParentElement = element; - for( let i = 0; i < element.childNodes.length; i++ ) { - const childElement = element.childNodes[i]; - if ( i > 0 ) { - let j = i - 1; - while ( j >= 0 ) { - const aPreviousChildElement = element.childNodes[j]; - if ( typeof aPreviousChildElement.setAttribute === 'function' && aPreviousChildElement.tagName !== "BR" ) { - previousParentElement = aPreviousChildElement; - break; - } - j = j - 1; - } - } - let parentSection = section; - if( childElement.nodeName === "section" ) { - parentSection = childElement ; - previousParentElement = childElement ; - } - if ( typeof childElement.setAttribute === 'function' || childElement.nodeType === Node.COMMENT_NODE ) { - addAttributes( parentSection, childElement, previousParentElement, separatorElementAttributes, separatorSectionAttributes ); - } - } - } - - if ( element.nodeType === Node.COMMENT_NODE ) { - if ( addAttributeInElement( element, previousElement, separatorElementAttributes ) === false ) { - addAttributeInElement( element, section, separatorSectionAttributes ); - } - } - } - - /** - * Converts any current data-markdown slides in the - * DOM to HTML. - */ - function convertSlides() { - - const sections = deck.getRevealElement().querySelectorAll( '[data-markdown]:not([data-markdown-parsed])'); - - [].slice.call( sections ).forEach( function( section ) { - - section.setAttribute( 'data-markdown-parsed', true ) - - const notes = section.querySelector( 'aside.notes' ); - const markdown = getMarkdownFromSlide( section ); - - section.innerHTML = marked( markdown ); - addAttributes( section, section, null, section.getAttribute( 'data-element-attributes' ) || - section.parentNode.getAttribute( 'data-element-attributes' ) || - DEFAULT_ELEMENT_ATTRIBUTES_SEPARATOR, - section.getAttribute( 'data-attributes' ) || - section.parentNode.getAttribute( 'data-attributes' ) || - DEFAULT_SLIDE_ATTRIBUTES_SEPARATOR); - - // If there were notes, we need to re-add them after - // having overwritten the section's HTML - if( notes ) { - section.appendChild( notes ); - } - - } ); - - return Promise.resolve(); - - } - - function escapeForHTML( input ) { - - return input.replace( /([&<>'"])/g, char => HTML_ESCAPE_MAP[char] ); - - } - - /** - * Parse the front matter from the Markdown document - * - * Returns updated options with the default metadata - * and updated content without the front matter - */ - function parseFrontMatter(content, options) { - options = getSlidifyOptions( options) - - const parsedFrontMatter = fm(content) - - content = parsedFrontMatter.body; - if (parsedFrontMatter.frontmatter){ - options.metadata = yaml.load(parsedFrontMatter.frontmatter); - if (!('slide' in options.metadata)) { - content = `Missing value of 'slide' in default metadata` - console.error(content) - delete options.metadata - } - } - return [content, options]; - } - - /** - * Separates the inline metadata and content for slide having inline metadata in yaml block as - * - * ```yaml - * metadata_key1: metadata_value1 - * metadata_key2: metadata_value2 - * ``` - */ - function extractYAMLMetadata(markdown, options) { - const markdownParts = yamlRegex.exec(markdown) - yamlRegex.lastIndex = 0 - if (markdownParts && markdownParts[2]) { - const metadata = markdownParts[2] - markdown = markdownParts[3] || '' - - try { - const metadataYAML = yaml.load(metadata) - if (metadataYAML === undefined) { - throw new Error("The inline metadata is not valid.") - } - options.metadata = { ...options.metadata, ...metadataYAML } - options.attributes = 'class=' + (options.metadata.slide || '') - } catch (error) { - console.error(error) - markdown = error.message - } - } - return [markdown, options] - } - - /** - * Separates the inline metadata and content for slides having metadata as - * - * ::metadata_key1:metadata_value1 ::metadata_key2:metadata_value2 - */ - function extractInlineMetadata(markdown, options) { - const inlineMetadata = {} - const matches = markdown.match(headingWithMetadataRegex) - - if (matches && matches[0]) { - const metadataMatches = matches[0].match(metadataRegex) - if (metadataMatches) { - metadataMatches.forEach(metadataMatch => { - const [key, value] = metadataMatch.replace('::', '').split(':') - inlineMetadata[key.trim()] = value.trim() - const metadataPattern = new RegExp(`::\\b${key.trim()}\\b:\\s*${value.trim()}`) - markdown = markdown.replace(metadataPattern, '') - }) - } - } - - options.metadata = { ...options.metadata, ...inlineMetadata } - options.attributes = 'class=' + (options.metadata.slide || '') - return [markdown, options] - } - - /** - * Separates the inline metadata and content for each slide - * - * Returns updated options with the inline metadata and - * updated markdown without the inline metadata for each slide - */ - function separateInlineMetadataAndMarkdown(markdown, options) { - const yamlMetadata = yamlRegex.test(markdown) - const newMetadata = headingWithMetadataRegex.test(markdown) - yamlRegex.lastIndex = 0 - - switch (true) { - case newMetadata: - [markdown, options] = extractInlineMetadata(markdown, options) - break - case yamlMetadata: - [markdown, options] = extractYAMLMetadata(markdown, options) - break - default: - if (options.metadata) { - options.attributes = 'class=' + (options.metadata.slide || '') - } - break - } - - return [markdown, options] - } - - /** - * Renders the template for each slide - * - * Returns the rendered template with the content - */ - function renderTemplate(content, options) { - try { - const titleRegex = /^#+\s*(.*?)\s*$/m - const matches = content.match(titleRegex) - let title - if(matches) { - title = matches[1].trim() - } - const slideContent = content.replace(titleRegex, '') - - options = getSlidifyOptions(options) - const url = new URL(import.meta.url); - const templatePath = `${url.origin}/templates/${options.metadata.slide}-template.html` - const xhr = new XMLHttpRequest() - xhr.open('GET', templatePath, false) - xhr.send() - let tempDiv = document.createElement('div'); - if (xhr.status === 200) { - tempDiv.innerHTML = Mustache.render(xhr.responseText, { title: title, content: slideContent, metadata: options.metadata }); - } else { - tempDiv.innerHTML = `Template for slide "${options.metadata.slide}" not found.` - console.error(`Failed to fetch template. Status: ${xhr.status}`); - } - return tempDiv.textContent; - } catch (error) { - console.error('Error:', error); - throw error; - } - } - - return { - id: 'markdown', - - /** - * Starts processing and converting Markdown within the - * current reveal.js deck. - */ - init: function( reveal ) { - - deck = reveal; - - let { renderer, animateLists, ...markedOptions } = deck.getConfig().markdown || {}; - - if( !renderer ) { - renderer = new marked.Renderer(); - - renderer.code = ( code, language ) => { - - // Off by default - let lineNumberOffset = ''; - let lineNumbers = ''; - - // Users can opt in to show line numbers and highlight - // specific lines. - // ```javascript [] show line numbers - // ```javascript [1,4-8] highlights lines 1 and 4-8 - // optional line number offset: - // ```javascript [25: 1,4-8] start line numbering at 25, - // highlights lines 1 (numbered as 25) and 4-8 (numbered as 28-32) - if( CODE_LINE_NUMBER_REGEX.test( language ) ) { - let lineNumberOffsetMatch = language.match( CODE_LINE_NUMBER_REGEX )[2]; - if (lineNumberOffsetMatch){ - lineNumberOffset = `data-ln-start-from="${lineNumberOffsetMatch.trim()}"`; - } - - lineNumbers = language.match( CODE_LINE_NUMBER_REGEX )[3].trim(); - lineNumbers = `data-line-numbers="${lineNumbers}"`; - language = language.replace( CODE_LINE_NUMBER_REGEX, '' ).trim(); - } - - // Escape before this gets injected into the DOM to - // avoid having the HTML parser alter our code before - // highlight.js is able to read it - code = escapeForHTML( code ); - - // return `
${code}
`; - - return `
${code}
`; - }; - } - - if( animateLists === true ) { - renderer.listitem = text => `
  • ${text}
  • `; - } - - marked.setOptions( { - renderer, - ...markedOptions - } ); - - return processSlides( deck.getRevealElement() ).then( convertSlides ); - - }, - - // TODO: Do these belong in the API? - processSlides: processSlides, - convertSlides: convertSlides, - slidify: slidify, - marked: marked, - parseFrontMatter: parseFrontMatter, - separateInlineMetadataAndMarkdown: separateInlineMetadataAndMarkdown - } - -}; - -export default Plugin; + // The reveal.js instance this plugin is attached to + let deck + + /** + * Retrieves the markdown contents of a slide section + * element. Normalizes leading tabs/whitespace. + */ + function getMarkdownFromSlide(section) { + // look for a ') + + const leadingWs = text.match(/^\n?(\s*)/)[1].length, + leadingTabs = text.match(/^\n?(\t*)/)[1].length + + if (leadingTabs > 0) { + text = text.replace(new RegExp('\\n?\\t{' + leadingTabs + '}(.*)', 'g'), function (m, p1) { + return '\n' + p1 + }) + } else if (leadingWs > 1) { + text = text.replace(new RegExp('\\n? {' + leadingWs + '}(.*)', 'g'), function (m, p1) { + return '\n' + p1 + }) + } + + return text + } + + /** + * Given a markdown slide section element, this will + * return all arguments that aren't related to markdown + * parsing. Used to forward any other user-defined arguments + * to the output markdown slide. + */ + function getForwardedAttributes(section) { + const attributes = section.attributes + const result = [] + + for (let i = 0, len = attributes.length; i < len; i++) { + const name = attributes[i].name, + value = attributes[i].value + + // disregard attributes that are used for markdown loading/parsing + if (/data\-(markdown|separator|vertical|notes)/gi.test(name)) continue + + if (value) { + result.push(name + '="' + value + '"') + } else { + result.push(name) + } + } + + return result.join(' ') + } + + /** + * Inspects the given options and fills out default + * values for what's not defined. + */ + function getSlidifyOptions(options) { + const markdownConfig = deck?.getConfig?.().markdown + + options = options || {} + options.separator = options.separator || markdownConfig?.separator || DEFAULT_SLIDE_SEPARATOR + options.verticalSeparator = + options.verticalSeparator || markdownConfig?.verticalSeparator || DEFAULT_VERTICAL_SEPARATOR + options.notesSeparator = options.notesSeparator || markdownConfig?.notesSeparator || DEFAULT_NOTES_SEPARATOR + options.attributes = options.attributes || '' + + return options + } + + /** + * Helper function for constructing a markdown slide. + */ + function createMarkdownSlide(content, options) { + options = getSlidifyOptions(options) + + const notesMatch = content.split(new RegExp(options.notesSeparator, 'mgi')) + + if (notesMatch.length === 2) { + content = notesMatch[0] + '' + } + + // prevent script end tags in the content from interfering + // with parsing + content = content.replace(/<\/script>/g, SCRIPT_END_PLACEHOLDER) + + // render the template with the content only if there is metadata + if (options.metadata) { + content = renderTemplate(content, options) + } + + return '' + } + + /** + * Parses a data string into multiple slides based + * on the passed in separator arguments. + */ + function slidify(markdown, options) { + options = getSlidifyOptions(options) + + const separatorRegex = new RegExp( + options.separator + (options.verticalSeparator ? '|' + options.verticalSeparator : ''), + 'mg' + ), + horizontalSeparatorRegex = new RegExp(options.separator) + + let matches, + lastIndex = 0, + isHorizontal, + wasHorizontal = true, + content, + sectionStack = [] + + // separates default metadata from the markdown file + ;[markdown, options] = parseFrontMatter(markdown, options) + + // iterate until all blocks between separators are stacked up + while ((matches = separatorRegex.exec(markdown))) { + const notes = null + + // determine direction (horizontal by default) + isHorizontal = horizontalSeparatorRegex.test(matches[0]) + + if (!isHorizontal && wasHorizontal) { + // create vertical stack + sectionStack.push([]) + } + + // pluck slide content from markdown input + content = markdown.substring(lastIndex, matches.index) + + if (isHorizontal && wasHorizontal) { + // add to horizontal stack + sectionStack.push(content) + } else { + // add to vertical stack + sectionStack[sectionStack.length - 1].push(content) + } + + lastIndex = separatorRegex.lastIndex + wasHorizontal = isHorizontal + } + + // add the remaining slide + ;(wasHorizontal ? sectionStack : sectionStack[sectionStack.length - 1]).push(markdown.substring(lastIndex)) + + let markdownSections = '' + + // flatten the hierarchical stack, and insert
    tags + for (let i = 0, len = sectionStack.length; i < len; i++) { + // slideOptions is created to avoid mutating the original options object with default metadata + let slideOptions = { ...options } + + // vertical + if (sectionStack[i] instanceof Array) { + markdownSections += '
    ' + + sectionStack[i].forEach(function (child) { + ;[content, slideOptions] = separateInlineMetadataAndMarkdown(child, slideOptions) + markdownSections += + '
    ' + + createMarkdownSlide(content, slideOptions) + + '
    ' + }) + + markdownSections += '
    ' + } else { + ;[content, slideOptions] = separateInlineMetadataAndMarkdown(sectionStack[i], slideOptions) + markdownSections += + '
    ' + + createMarkdownSlide(content, slideOptions) + + '
    ' + } + } + + return markdownSections + } + + /** + * Parses any current data-markdown slides, splits + * multi-slide markdown into separate sections and + * handles loading of external markdown. + */ + function processSlides(scope) { + return new Promise(function (resolve) { + const externalPromises = [] + + ;[].slice + .call(scope.querySelectorAll('section[data-markdown]:not([data-markdown-parsed])')) + .forEach(function (section, i) { + if (section.getAttribute('data-markdown').length) { + externalPromises.push( + loadExternalMarkdown(section).then( + // Finished loading external file + function (xhr, url) { + section.outerHTML = slidify(xhr.responseText, { + separator: section.getAttribute('data-separator'), + verticalSeparator: section.getAttribute('data-separator-vertical'), + notesSeparator: section.getAttribute('data-separator-notes'), + attributes: getForwardedAttributes(section), + }) + }, + + // Failed to load markdown + function (xhr, url) { + section.outerHTML = + '
    ' + + 'ERROR: The attempt to fetch ' + + url + + ' failed with HTTP status ' + + xhr.status + + '.' + + "Check your browser's JavaScript console for more details." + + '

    Remember that you need to serve the presentation HTML from a HTTP server.

    ' + + '
    ' + } + ) + ) + } else { + section.outerHTML = slidify(getMarkdownFromSlide(section), { + separator: section.getAttribute('data-separator'), + verticalSeparator: section.getAttribute('data-separator-vertical'), + notesSeparator: section.getAttribute('data-separator-notes'), + attributes: getForwardedAttributes(section), + }) + } + }) + + Promise.all(externalPromises).then(resolve) + }) + } + + function loadExternalMarkdown(section) { + return new Promise(function (resolve, reject) { + const xhr = new XMLHttpRequest(), + url = section.getAttribute('data-markdown') + + const datacharset = section.getAttribute('data-charset') + + // see https://developer.mozilla.org/en-US/docs/Web/API/element.getAttribute#Notes + if (datacharset !== null && datacharset !== '') { + xhr.overrideMimeType('text/html; charset=' + datacharset) + } + + xhr.onreadystatechange = function (section, xhr) { + if (xhr.readyState === 4) { + // file protocol yields status code 0 (useful for local debug, mobile applications etc.) + if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 0) { + resolve(xhr, url) + } else { + reject(xhr, url) + } + } + }.bind(this, section, xhr) + + xhr.open('GET', url, true) + + try { + xhr.send() + } catch (e) { + 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 + ) + resolve(xhr, url) + } + }) + } + + /** + * Check if a node value has the attributes pattern. + * If yes, extract it and add that value as one or several attributes + * to the target element. + * + * You need Cache Killer on Chrome to see the effect on any FOM transformation + * directly on refresh (F5) + * http://stackoverflow.com/questions/5690269/disabling-chrome-cache-for-website-development/7000899#answer-11786277 + */ + function addAttributeInElement(node, elementTarget, separator) { + const markdownClassesInElementsRegex = new RegExp(separator, 'mg') + const markdownClassRegex = new RegExp('([^"= ]+?)="([^"]+?)"|(data-[^"= ]+?)(?=[" ])', 'mg') + let nodeValue = node.nodeValue + let matches, matchesClass + if ((matches = markdownClassesInElementsRegex.exec(nodeValue))) { + const classes = matches[1] + nodeValue = + nodeValue.substring(0, matches.index) + nodeValue.substring(markdownClassesInElementsRegex.lastIndex) + node.nodeValue = nodeValue + while ((matchesClass = markdownClassRegex.exec(classes))) { + if (matchesClass[2]) { + elementTarget.setAttribute(matchesClass[1], matchesClass[2]) + } else { + elementTarget.setAttribute(matchesClass[3], '') + } + } + return true + } + return false + } + + /** + * Add attributes to the parent element of a text node, + * or the element of an attribute node. + */ + function addAttributes(section, element, previousElement, separatorElementAttributes, separatorSectionAttributes) { + if (element !== null && element.childNodes !== undefined && element.childNodes.length > 0) { + let previousParentElement = element + for (let i = 0; i < element.childNodes.length; i++) { + const childElement = element.childNodes[i] + if (i > 0) { + let j = i - 1 + while (j >= 0) { + const aPreviousChildElement = element.childNodes[j] + if ( + typeof aPreviousChildElement.setAttribute === 'function' && + aPreviousChildElement.tagName !== 'BR' + ) { + previousParentElement = aPreviousChildElement + break + } + j = j - 1 + } + } + let parentSection = section + if (childElement.nodeName === 'section') { + parentSection = childElement + previousParentElement = childElement + } + if (typeof childElement.setAttribute === 'function' || childElement.nodeType === Node.COMMENT_NODE) { + addAttributes( + parentSection, + childElement, + previousParentElement, + separatorElementAttributes, + separatorSectionAttributes + ) + } + } + } + + if (element.nodeType === Node.COMMENT_NODE) { + if (addAttributeInElement(element, previousElement, separatorElementAttributes) === false) { + addAttributeInElement(element, section, separatorSectionAttributes) + } + } + } + + /** + * Converts any current data-markdown slides in the + * DOM to HTML. + */ + function convertSlides() { + const sections = deck.getRevealElement().querySelectorAll('[data-markdown]:not([data-markdown-parsed])') + + ;[].slice.call(sections).forEach(function (section) { + section.setAttribute('data-markdown-parsed', true) + + const notes = section.querySelector('aside.notes') + const markdown = getMarkdownFromSlide(section) + + section.innerHTML = marked(markdown) + addAttributes( + section, + section, + null, + section.getAttribute('data-element-attributes') || + section.parentNode.getAttribute('data-element-attributes') || + DEFAULT_ELEMENT_ATTRIBUTES_SEPARATOR, + section.getAttribute('data-attributes') || + section.parentNode.getAttribute('data-attributes') || + DEFAULT_SLIDE_ATTRIBUTES_SEPARATOR + ) + + // If there were notes, we need to re-add them after + // having overwritten the section's HTML + if (notes) { + section.appendChild(notes) + } + }) + + return Promise.resolve() + } + + function escapeForHTML(input) { + return input.replace(/([&<>'"])/g, (char) => HTML_ESCAPE_MAP[char]) + } + + /** + * Parse the front matter from the Markdown document + * + * Returns updated options with the default metadata + * and updated content without the front matter + */ + function parseFrontMatter(content, options) { + options = getSlidifyOptions(options) + + const parsedFrontMatter = fm(content) + + content = parsedFrontMatter.body + if (parsedFrontMatter.frontmatter) { + options.metadata = yaml.load(parsedFrontMatter.frontmatter) + if (!('slide' in options.metadata)) { + content = 'Missing value of "slide" in default metadata' + console.error(content) + delete options.metadata + } + } + return [content, options] + } + + /** + * Separates the inline metadata and content for slide having inline metadata in yaml block as + * + * ```yaml + * metadata_key1: metadata_value1 + * metadata_key2: metadata_value2 + * ``` + */ + function extractYAMLMetadata(markdown, options) { + const markdownParts = yamlRegex.exec(markdown) + yamlRegex.lastIndex = 0 + if (markdownParts && markdownParts[2]) { + const metadata = markdownParts[2] + markdown = markdownParts[3] || '' + + try { + const metadataYAML = yaml.load(metadata) + if (metadataYAML === undefined) { + throw new Error('The inline metadata is not valid.') + } + options.metadata = { ...options.metadata, ...metadataYAML } + options.attributes = 'class=' + (options.metadata.slide || '') + } catch (error) { + console.error(error) + markdown = error.message + } + } + return [markdown, options] + } + + /** + * Separates the inline metadata and content for slides having metadata as + * + * ::metadata_key1:metadata_value1 ::metadata_key2:metadata_value2 + */ + function extractInlineMetadata(markdown, options) { + const inlineMetadata = {} + const matches = markdown.match(headingWithMetadataRegex) + + if (matches && matches[0]) { + const metadataMatches = matches[0].match(metadataRegex) + if (metadataMatches) { + metadataMatches.forEach((metadataMatch) => { + const [key, value] = metadataMatch.replace('::', '').split(':') + inlineMetadata[key.trim()] = value.trim() + const metadataPattern = new RegExp(`::\\b${key.trim()}\\b:\\s*${value.trim()}`) + markdown = markdown.replace(metadataPattern, '') + }) + } + } + + options.metadata = { ...options.metadata, ...inlineMetadata } + options.attributes = 'class=' + (options.metadata.slide || '') + return [markdown, options] + } + + /** + * Separates the inline metadata and content for each slide + * + * Returns updated options with the inline metadata and + * updated markdown without the inline metadata for each slide + */ + function separateInlineMetadataAndMarkdown(markdown, options) { + const yamlMetadata = yamlRegex.test(markdown) + const newMetadata = headingWithMetadataRegex.test(markdown) + yamlRegex.lastIndex = 0 + + switch (true) { + case newMetadata: + ;[markdown, options] = extractInlineMetadata(markdown, options) + break + case yamlMetadata: + ;[markdown, options] = extractYAMLMetadata(markdown, options) + break + default: + if (options.metadata) { + options.attributes = 'class=' + (options.metadata.slide || '') + } + break + } + + return [markdown, options] + } + + /** + * Renders the template for each slide + * + * Returns the rendered template with the content + */ + function renderTemplate(content, options) { + try { + const titleRegex = /^#+\s*(.*?)\s*$/m + const matches = content.match(titleRegex) + let title + if (matches) { + title = matches[1].trim() + } + const slideContent = content.replace(titleRegex, '') + + options = getSlidifyOptions(options) + const url = new URL(import.meta.url) + const templatePath = `${url.origin}/templates/${options.metadata.slide}-template.html` + const xhr = new XMLHttpRequest() + xhr.open('GET', templatePath, false) + xhr.send() + let tempDiv = document.createElement('div') + if (xhr.status === 200) { + tempDiv.innerHTML = Mustache.render(xhr.responseText, { + title: title, + content: slideContent, + metadata: options.metadata, + }) + } else { + tempDiv.innerHTML = `Template for slide "${options.metadata.slide}" not found.` + console.error(`Failed to fetch template. Status: ${xhr.status}`) + } + return tempDiv.textContent + } catch (error) { + console.error('Error:', error) + throw error + } + } + + return { + id: 'markdown', + + /** + * Starts processing and converting Markdown within the + * current reveal.js deck. + */ + init: function (reveal) { + deck = reveal + + let { renderer, animateLists, ...markedOptions } = deck.getConfig().markdown || {} + + if (!renderer) { + renderer = new marked.Renderer() + + renderer.code = (code, language) => { + // Off by default + let lineNumberOffset = '' + let lineNumbers = '' + + // Users can opt in to show line numbers and highlight + // specific lines. + // ```javascript [] show line numbers + // ```javascript [1,4-8] highlights lines 1 and 4-8 + // optional line number offset: + // ```javascript [25: 1,4-8] start line numbering at 25, + // highlights lines 1 (numbered as 25) and 4-8 (numbered as 28-32) + if (CODE_LINE_NUMBER_REGEX.test(language)) { + let lineNumberOffsetMatch = language.match(CODE_LINE_NUMBER_REGEX)[2] + if (lineNumberOffsetMatch) { + lineNumberOffset = `data-ln-start-from="${lineNumberOffsetMatch.trim()}"` + } + + lineNumbers = language.match(CODE_LINE_NUMBER_REGEX)[3].trim() + lineNumbers = `data-line-numbers="${lineNumbers}"` + language = language.replace(CODE_LINE_NUMBER_REGEX, '').trim() + } + + // Escape before this gets injected into the DOM to + // avoid having the HTML parser alter our code before + // highlight.js is able to read it + code = escapeForHTML(code) + + // return `
    ${code}
    `; + + return `
    ${code}
    ` + } + } + + if (animateLists === true) { + renderer.listitem = (text) => `
  • ${text}
  • ` + } + + marked.setOptions({ + renderer, + ...markedOptions, + }) + + return processSlides(deck.getRevealElement()).then(convertSlides) + }, + + // TODO: Do these belong in the API? + processSlides: processSlides, + convertSlides: convertSlides, + slidify: slidify, + marked: marked, + parseFrontMatter: parseFrontMatter, + separateInlineMetadataAndMarkdown: separateInlineMetadataAndMarkdown, + } +} + +export default Plugin diff --git a/tests/unit/jest/extractMetadata.spec.js b/tests/unit/jest/extractMetadata.spec.js index c1bec49..97f8120 100644 --- a/tests/unit/jest/extractMetadata.spec.js +++ b/tests/unit/jest/extractMetadata.spec.js @@ -1,8 +1,9 @@ import Plugin from '../../../plugin/awesoMD/plugin' +// eslint-disable-next-line new-cap const plugin = Plugin() -describe("separateInlineMetadataAndMarkdown", () => { +describe('separateInlineMetadataAndMarkdown', () => { afterEach(() => { jest.restoreAllMocks() }) @@ -10,48 +11,40 @@ describe("separateInlineMetadataAndMarkdown", () => { it.each([ [ [ - '```yaml\n' + - 'slide: cover\n' + - 'toc: false\n' + - '```\n' + - '# Cover Slide', - '```yaml\n' + - 'slide: section\n' + - '```\n' + - '# Section Slide', - '# Title Content Slide\n' + - 'some content' + '```yaml\n' + 'slide: cover\n' + 'toc: false\n' + '```\n' + '# Cover Slide', + '```yaml\n' + 'slide: section\n' + '```\n' + '# Section Slide', + '# Title Content Slide\n' + 'some content', ], [ [ { - "description": "some description", - "footer": "footer content", - "slide": "cover", - "presenter": "presenter name", - "toc": false + description: 'some description', + footer: 'footer content', + slide: 'cover', + presenter: 'presenter name', + toc: false, }, - '# Cover Slide' + '# Cover Slide', ], [ { - "description": "some description", - "footer": "footer content", - "slide": "section", - "presenter": "presenter name" + description: 'some description', + footer: 'footer content', + slide: 'section', + presenter: 'presenter name', }, - '# Section Slide' + '# Section Slide', ], [ { - "description": "some description", - "footer": "footer content", - "slide": "title-content", - "presenter": "presenter name" + description: 'some description', + footer: 'footer content', + slide: 'title-content', + presenter: 'presenter name', }, - '# Title Content Slide\nsome content' - ] - ] + '# Title Content Slide\nsome content', + ], + ], ], [ [ @@ -59,75 +52,78 @@ describe("separateInlineMetadataAndMarkdown", () => { '# Section Slide ::slide:section', '# Title Content Slide\nsome content', '# Title with colon slide:notavalue ::slide:cover ::toc:false', - '# Cover Slide ::slide:cover ::toc:false ::toc:true' + '# Cover Slide ::slide:cover ::toc:false ::toc:true', ], [ [ { - "description": "some description", - "footer": "footer content", - "slide": "cover", - "presenter": "presenter name", - "toc": "false" + description: 'some description', + footer: 'footer content', + slide: 'cover', + presenter: 'presenter name', + toc: 'false', }, - '# Cover Slide' + '# Cover Slide', ], [ { - "description": "some description", - "footer": "footer content", - "slide": "section", - "presenter": "presenter name" + description: 'some description', + footer: 'footer content', + slide: 'section', + presenter: 'presenter name', }, - '# Section Slide' + '# Section Slide', ], [ { - "description": "some description", - "footer": "footer content", - "slide": "title-content", - "presenter": "presenter name" + description: 'some description', + footer: 'footer content', + slide: 'title-content', + presenter: 'presenter name', }, - '# Title Content Slide\nsome content' + '# Title Content Slide\nsome content', ], [ { - "description": "some description", - "footer": "footer content", - "slide": "cover", - "presenter": "presenter name", - "toc": "false" + description: 'some description', + footer: 'footer content', + slide: 'cover', + presenter: 'presenter name', + toc: 'false', }, - '# Title with colon slide:notavalue' + '# Title with colon slide:notavalue', ], [ { - "description": "some description", - "footer": "footer content", - "slide": "cover", - "presenter": "presenter name", - "toc": "true" + description: 'some description', + footer: 'footer content', + slide: 'cover', + presenter: 'presenter name', + toc: 'true', }, - '# Cover Slide' - ] - ] - ] - ])('should separate and return expected metadata and markdown content', (slideContent, expectedSeparatedMetadataAndMarkdown) => { - const slideOptions = { - "metadata": { - "description": "some description", - "footer": "footer content", - "slide": "title-content", - "presenter": "presenter name" + '# Cover Slide', + ], + ], + ], + ])( + 'should separate and return expected metadata and markdown content', + (slideContent, expectedSeparatedMetadataAndMarkdown) => { + const slideOptions = { + metadata: { + description: 'some description', + footer: 'footer content', + slide: 'title-content', + presenter: 'presenter name', + }, } - } - const spySeparateInlineMetadataAndMarkdown = jest.spyOn(plugin, 'separateInlineMetadataAndMarkdown') - slideContent.forEach((slide, index) => { - const [content, options] = spySeparateInlineMetadataAndMarkdown(slide, { ...slideOptions }) - expect(options.metadata).toEqual(expectedSeparatedMetadataAndMarkdown[index][0]) - expect(content.trim()).toEqual(expectedSeparatedMetadataAndMarkdown[index][1]) - }) + const spySeparateInlineMetadataAndMarkdown = jest.spyOn(plugin, 'separateInlineMetadataAndMarkdown') + slideContent.forEach((slide, index) => { + const [content, options] = spySeparateInlineMetadataAndMarkdown(slide, { ...slideOptions }) + expect(options.metadata).toEqual(expectedSeparatedMetadataAndMarkdown[index][0]) + expect(content.trim()).toEqual(expectedSeparatedMetadataAndMarkdown[index][1]) + }) - expect(spySeparateInlineMetadataAndMarkdown).toHaveBeenCalledTimes(slideContent.length) - }) + expect(spySeparateInlineMetadataAndMarkdown).toHaveBeenCalledTimes(slideContent.length) + } + ) })