Bladeren bron

feature(extensionLoading): refactor extension loading mechanism

Estevão Soares dos Santos 10 jaren geleden
bovenliggende
commit
33f64f60c9

+ 2 - 0
Gruntfile.js

@@ -18,12 +18,14 @@ module.exports = function (grunt) {
         src:  [
           'src/showdown.js',
           'src/helpers.js',
+          'src/converter.js',
           'src/subParsers/*.js',
           'src/loader.js'
         ],
         dest: 'dist/<%= pkg.name %>.js'
       }
     },
+
     uglify: {
       options: {
         sourceMap: true,

+ 430 - 213
dist/showdown.js

@@ -1,4 +1,4 @@
-;/*! showdown 28-05-2015 */
+;/*! showdown 31-05-2015 */
 (function(){
 /**
  * Created by Tivie on 06-01-2015.
@@ -8,10 +8,11 @@
 var showdown = {},
     parsers = {},
     extensions = {},
-    globalOptions = {
+    defaultOptions = {
       omitExtraWLInCodeBlocks: false,
       prefixHeaderId:          false
-    };
+    },
+    globalOptions = JSON.parse(JSON.stringify(defaultOptions)); //clone default options out of laziness =P
 
 /**
  * helper namespace
@@ -19,7 +20,10 @@ var showdown = {},
  */
 showdown.helper = {};
 
-// Public properties
+/**
+ * TODO LEGACY SUPPORT CODE
+ * @type {{}}
+ */
 showdown.extensions = {};
 
 /**
@@ -56,6 +60,11 @@ showdown.getOptions = function () {
   return globalOptions;
 };
 
+showdown.resetOptions = function () {
+  'use strict';
+  globalOptions = JSON.parse(JSON.stringify(defaultOptions));
+};
+
 /**
  * Get or set a subParser
  *
@@ -81,6 +90,13 @@ showdown.subParser = function (name, func) {
   }
 };
 
+/**
+ * Gets or registers an extension
+ * @static
+ * @param {string} name
+ * @param {object|function=} ext
+ * @returns {*}
+ */
 showdown.extension = function (name, ext) {
   'use strict';
 
@@ -90,230 +106,151 @@ showdown.extension = function (name, ext) {
 
   name = showdown.helper.stdExtName(name);
 
+  // Getter
   if (showdown.helper.isUndefined(ext)) {
-    return getExtension();
+    if (!extensions.hasOwnProperty(name)) {
+      throw Error('Extension named ' + name + ' is not registered!');
+    }
+    return extensions[name];
+
+    // Setter
   } else {
-    return setExtension();
-  }
-};
+    if (typeof ext === 'function') {
+      ext = ext();
+    }
 
-function getExtension(name) {
-  'use strict';
+    var validExtension = validate(ext, name);
 
-  if (!extensions.hasOwnProperty(name)) {
-    throw Error('Extension named ' + name + ' is not registered!');
+    if (validExtension.valid) {
+      extensions[name] = ext;
+    } else {
+      throw Error(validExtension.error);
+    }
   }
-  return extensions[name];
-}
+};
 
-function setExtension(name, ext) {
+/**
+ * Gets all extensions registered
+ * @returns {{}}
+ */
+showdown.getAllExtensions = function () {
   'use strict';
+  return extensions;
+};
 
-  if (typeof ext !== 'object') {
-    throw Error('A Showdown Extension must be an object, ' + typeof ext + ' given');
-  }
-
-  if (!showdown.helper.isString(ext.type)) {
-    throw Error('When registering a showdown extension, "type" must be a string, ' + typeof ext.type + ' given');
-  }
-
-  ext.type = ext.type.toLowerCase();
-
-  extensions[name] = ext;
-}
+/**
+ * Remove an extension
+ * @param {string} name
+ */
+showdown.removeExtension = function (name) {
+  'use strict';
+  delete extensions[name];
+};
 
 /**
- * Showdown Converter class
- *
- * @param {object} [converterOptions]
- * @returns {{makeHtml: Function}}
+ * Removes all extensions
  */
-showdown.Converter = function (converterOptions) {
+showdown.resetExtensions = function () {
   'use strict';
+  extensions = {};
+};
 
-  converterOptions = converterOptions || {};
+/**
+ * Validate extension
+ * @param {object} ext
+ * @param {string} name
+ * @returns {{valid: boolean, error: string}}
+ */
+function validate(ext, name) {
+  'use strict';
 
-  var options = {},
-      langExtensions = [],
-      outputModifiers = [],
-      parserOrder = [
-        'githubCodeBlocks',
-        'hashHTMLBlocks',
-        'stripLinkDefinitions',
-        'blockGamut',
-        'unescapeSpecialChars'
-      ];
+  var baseMsg = (name) ? 'Error in ' + name + ' extension: ' : 'Error in unnamed extension',
+    ret = {
+      valid: true,
+      error: baseMsg
+    };
 
-  for (var gOpt in globalOptions) {
-    if (globalOptions.hasOwnProperty(gOpt)) {
-      options[gOpt] = globalOptions[gOpt];
-    }
+  if (typeof ext !== 'object') {
+    ret.valid = false;
+    ret.error = baseMsg + 'it must be an object, but ' + typeof ext + ' given';
+    return ret;
   }
 
-  // Merge options
-  if (typeof converterOptions === 'object') {
-    for (var opt in converterOptions) {
-      if (converterOptions.hasOwnProperty(opt)) {
-        options[opt] = converterOptions[opt];
-      }
-    }
+  if (!showdown.helper.isString(ext.type)) {
+    ret.valid = false;
+    ret.error = baseMsg + 'property "type" must be a string, but ' + typeof ext.type + ' given';
+    return ret;
   }
 
-  // This is a dirty workaround to maintain backwards extension compatibility
-  // We define a self var (which is a copy of this) and inject the makeHtml function
-  // directly to it. This ensures a full converter object is available when iterating over extensions
-  // We should rewrite the extension loading mechanism and use some kind of interface or decorator pattern
-  // and inject the object reference there instead.
-  var self = this;
-  self.makeHtml = makeHtml;
-
-  // Parse options
-  if (options.extensions) {
+  var type = ext.type = ext.type.toLowerCase();
 
-    // Iterate over each plugin
-    showdown.helper.forEach(options.extensions, function (plugin) {
-      var pluginName = plugin;
-
-      // Assume it's a bundled plugin if a string is given
-      if (typeof plugin === 'string') {
-        var tPluginName = showdown.helper.stdExtName(plugin);
-
-        if (!showdown.helper.isUndefined(showdown.extensions[tPluginName]) && showdown.extensions[tPluginName]) {
-          //Trigger some kind of deprecated alert
-          plugin = showdown.extensions[tPluginName];
-
-        } else if (!showdown.helper.isUndefined(extensions[tPluginName])) {
-          plugin = extensions[tPluginName];
-        }
-      }
+  // normalize extension type
+  if (type === 'language') {
+    type = ext.type = 'lang';
+  }
 
-      if (typeof plugin === 'function') {
-        // Iterate over each extension within that plugin
-        showdown.helper.forEach(plugin(self), function (ext) {
-          // Sort extensions by type
-          if (ext.type) {
-            if (ext.type === 'language' || ext.type === 'lang') {
-              langExtensions.push(ext);
-            } else if (ext.type === 'output' || ext.type === 'html') {
-              outputModifiers.push(ext);
-            }
-          } else {
-            // Assume language extension
-            outputModifiers.push(ext);
-          }
-        });
-      } else {
-        var errMsg = 'An extension could not be loaded. It was either not found or is not a valid extension.';
-        if (typeof pluginName === 'string') {
-          errMsg = 'Extension "' + pluginName + '" could not be loaded.  It was either not found or is not a valid extension.';
-        }
-        throw errMsg;
-      }
-    });
+  if (type === 'html') {
+    type = ext.type = 'output';
   }
 
-  /**
-   * Converts a markdown string into HTML
-   * @param {string} text
-   * @returns {*}
-   */
-  function makeHtml(text) {
+  if (type !== 'lang' && type !== 'output') {
+    ret.valid = false;
+    ret.error = baseMsg + 'type ' + type + ' is not recognized. Valid values: "lang" or "output"';
+    return ret;
+  }
 
-    //check if text is not falsy
-    if (!text) {
-      return text;
+  if (ext.filter) {
+    if (typeof ext.filter !== 'function') {
+      ret.valid = false;
+      ret.error = baseMsg + '"filter" must be a function, but ' + typeof ext.filter + ' given';
+      return ret;
     }
 
-    var globals = {
-      gHtmlBlocks:     [],
-      gUrls:           {},
-      gTitles:         {},
-      gListLevel:      0,
-      hashLinkCounts:  {},
-      langExtensions:  langExtensions,
-      outputModifiers: outputModifiers
-    };
-
-    // attacklab: Replace ~ with ~T
-    // This lets us use tilde as an escape char to avoid md5 hashes
-    // The choice of character is arbitrary; anything that isn't
-    // magic in Markdown will work.
-    text = text.replace(/~/g, '~T');
-
-    // attacklab: Replace $ with ~D
-    // RegExp interprets $ as a special character
-    // when it's in a replacement string
-    text = text.replace(/\$/g, '~D');
-
-    // Standardize line endings
-    text = text.replace(/\r\n/g, '\n'); // DOS to Unix
-    text = text.replace(/\r/g, '\n'); // Mac to Unix
-
-    // Make sure text begins and ends with a couple of newlines:
-    text = '\n\n' + text + '\n\n';
-
-    // detab
-    text = parsers.detab(text, options, globals);
-
-    // stripBlankLines
-    text = parsers.stripBlankLines(text, options, globals);
-
-    //run languageExtensions
-    text = parsers.languageExtensions(text, options, globals);
-
-    // Run all registered parsers
-    for (var i = 0; i < parserOrder.length; ++i) {
-      var name = parserOrder[i];
-      text = parsers[name](text, options, globals);
+  } else if (ext.regex) {
+    if (showdown.helper.isString(ext.regex)) {
+      ext.regex = new RegExp(ext.regex, 'g');
+    }
+    if (!ext.regex instanceof RegExp) {
+      ret.valid = false;
+      ret.error = baseMsg + '"regex" property must either be a string or a RegExp object, but ' +
+        typeof ext.regex + ' given';
+      return ret;
+    }
+    if (showdown.helper.isUndefined(ext.replace)) {
+      ret.valid = false;
+      ret.error = baseMsg + '"regex" extensions must implement a replace string or function';
+      return ret;
     }
 
-    // attacklab: Restore dollar signs
-    text = text.replace(/~D/g, '$$');
-
-    // attacklab: Restore tildes
-    text = text.replace(/~T/g, '~');
-
-    // Run output modifiers
-    showdown.helper.forEach(globals.outputModifiers, function (ext) {
-      text = showdown.subParser('runExtension')(ext, text);
-    });
-    text = parsers.outputModifiers(text, options, globals);
-
-    return text;
+  } else {
+    ret.valid = false;
+    ret.error = baseMsg + 'extensions must define either a "regex" property or a "filter" method';
+    return ret;
   }
 
-  /**
-   * Set an option of this Converter instance
-   * @param {string} key
-   * @param {*} value
-   */
-  function setOption (key, value) {
-    options[key] = value;
+  if (showdown.helper.isUndefined(ext.filter) && showdown.helper.isUndefined(ext.regex)) {
+    ret.valid = false;
+    ret.error = baseMsg + 'output extensions must define a filter property';
+    return ret;
   }
 
-  /**
-   * Get the option of this Converter instance
-   * @param {string} key
-   * @returns {*}
-   */
-  function getOption(key) {
-    return options[key];
-  }
+  return ret;
+}
 
-  /**
-   * Get the options of this Converter instance
-   * @returns {{}}
-   */
-  function getOptions() {
-    return options;
-  }
+/**
+ * Validate extension
+ * @param {object} ext
+ * @returns {boolean}
+ */
+showdown.validateExtension = function (ext) {
+  'use strict';
 
-  return {
-    makeHtml: makeHtml,
-    setOption: setOption,
-    getOption: getOption,
-    getOptions: getOptions
-  };
+  var validateExtension = validate(ext, null);
+  if (!validateExtension.valid) {
+    console.warn(validateExtension.error);
+    return false;
+  }
+  return true;
 };
 
 /**
@@ -424,6 +361,291 @@ showdown.helper.escapeCharacters = function escapeCharacters(text, charsToEscape
   return text;
 };
 
+/**
+ * POLYFILLS
+ */
+if (showdown.helper.isUndefined(console)) {
+  console = {
+    warn: function (msg) {
+      'use strict';
+      alert(msg);
+    },
+    log: function (msg) {
+      'use strict';
+      alert(msg);
+    }
+  };
+}
+
+/**
+ * Created by Estevao on 31-05-2015.
+ */
+
+/**
+ * Showdown Converter class
+ * @class
+ * @param {object} [converterOptions]
+ * @returns {
+ *  {makeHtml: Function},
+ *  {setOption: Function},
+ *  {getOption: Function},
+ *  {getOptions: Function}
+ * }
+ */
+showdown.Converter = function (converterOptions) {
+  'use strict';
+
+  var
+      /**
+       * Options used by this converter
+       * @private
+       * @type {{}}
+       */
+      options = {
+        omitExtraWLInCodeBlocks: false,
+        prefixHeaderId:          false
+      },
+
+      /**
+       * Language extensions used by this converter
+       * @private
+       * @type {Array}
+       */
+      langExtensions = [],
+
+      /**
+       * Output modifiers extensions used by this converter
+       * @private
+       * @type {Array}
+       */
+      outputModifiers = [],
+
+      /**
+       * The parser Order
+       * @private
+       * @type {string[]}
+       */
+      parserOrder = [
+        'githubCodeBlocks',
+        'hashHTMLBlocks',
+        'stripLinkDefinitions',
+        'blockGamut',
+        'unescapeSpecialChars'
+      ];
+
+  _constructor();
+
+  /**
+   * Converter constructor
+   * @private
+   */
+  function _constructor() {
+    converterOptions = converterOptions || {};
+
+    for (var gOpt in globalOptions) {
+      if (globalOptions.hasOwnProperty(gOpt)) {
+        options[gOpt] = globalOptions[gOpt];
+      }
+    }
+
+    // Merge options
+    if (typeof converterOptions === 'object') {
+      for (var opt in converterOptions) {
+        if (converterOptions.hasOwnProperty(opt)) {
+          options[opt] = converterOptions[opt];
+        }
+      }
+    }
+
+    if (options.extensions) {
+      showdown.helper.forEach(options.extensions, _parseExtension);
+    }
+  }
+
+  /**
+   * Parse extension
+   * @param {*} ext
+   * @private
+   */
+  function _parseExtension(ext) {
+
+    // If it's a string, the extension was previously loaded
+    if (showdown.helper.isString(ext)) {
+      ext = showdown.helper.stdExtName(ext);
+
+      // TODO LEGACY SUPPORT CODE
+      if (!showdown.helper.isUndefined(showdown.extensions[ext]) && showdown.extensions[ext]) {
+        console.warn(ext + ' is an old extension that uses a deprecated loading method.' +
+          'Please inform the developer that the extension should be updated!');
+        ext = showdown.extensions[ext];
+      // END LEGACY SUPPORT CODE
+
+      } else if (!showdown.helper.isUndefined(extensions[ext])) {
+        ext = extensions[ext];
+
+      } else {
+        throw Error('Extension "' + ext + '" could not be loaded. It was either not found or is not a valid extension.');
+      }
+    } else if (typeof ext === 'function') {
+      ext = ext();
+    }
+
+    if (!showdown.validateExtension(ext)) {
+      return;
+    }
+
+    switch (ext.type) {
+      case 'lang':
+        langExtensions.push(ext);
+        break;
+
+      case 'output':
+        outputModifiers.push(ext);
+        break;
+
+      default:
+        // should never reach here
+        throw Error('Extension loader error: Type unrecognized!!!');
+    }
+  }
+
+  /**
+   * Converts a markdown string into HTML
+   * @param {string} text
+   * @returns {*}
+   */
+  this.makeHtml = function (text) {
+    //check if text is not falsy
+    if (!text) {
+      return text;
+    }
+
+    var globals = {
+      gHtmlBlocks:     [],
+      gUrls:           {},
+      gTitles:         {},
+      gListLevel:      0,
+      hashLinkCounts:  {},
+      langExtensions:  langExtensions,
+      outputModifiers: outputModifiers,
+      converter:       this
+    };
+
+    // attacklab: Replace ~ with ~T
+    // This lets us use tilde as an escape char to avoid md5 hashes
+    // The choice of character is arbitrary; anything that isn't
+    // magic in Markdown will work.
+    text = text.replace(/~/g, '~T');
+
+    // attacklab: Replace $ with ~D
+    // RegExp interprets $ as a special character
+    // when it's in a replacement string
+    text = text.replace(/\$/g, '~D');
+
+    // Standardize line endings
+    text = text.replace(/\r\n/g, '\n'); // DOS to Unix
+    text = text.replace(/\r/g, '\n'); // Mac to Unix
+
+    // Make sure text begins and ends with a couple of newlines:
+    text = '\n\n' + text + '\n\n';
+
+    // detab
+    text = showdown.subParser('detab')(text, options, globals);
+
+    // stripBlankLines
+    text = showdown.subParser('stripBlankLines')(text, options, globals);
+
+    //run languageExtensions
+    showdown.helper.forEach(langExtensions, function (ext) {
+      text = showdown.subParser('runExtension')(ext, text, options, globals);
+    });
+
+    // Run all registered parsers
+    for (var i = 0; i < parserOrder.length; ++i) {
+      var name = parserOrder[i];
+      text = parsers[name](text, options, globals);
+    }
+
+    // attacklab: Restore dollar signs
+    text = text.replace(/~D/g, '$$');
+
+    // attacklab: Restore tildes
+    text = text.replace(/~T/g, '~');
+
+    // Run output modifiers
+    showdown.helper.forEach(outputModifiers, function (ext) {
+      text = showdown.subParser('runExtension')(ext, text, options, globals);
+    });
+    text = parsers.outputModifiers(text, options, globals);
+
+    return text;
+  };
+
+  /**
+   * Set an option of this Converter instance
+   * @param {string} key
+   * @param {*} value
+   */
+  this.setOption = function (key, value) {
+    options[key] = value;
+  };
+
+  /**
+   * Get the option of this Converter instance
+   * @param {string} key
+   * @returns {*}
+   */
+  this.getOption = function (key) {
+    return options[key];
+  };
+
+  /**
+   * Get the options of this Converter instance
+   * @returns {{}}
+   */
+  this.getOptions = function () {
+    return options;
+  };
+
+  /**
+   * Add extension to THIS converter
+   * @param {{}} extension
+   */
+  this.addExtension = function (extension) {
+    _parseExtension(extension);
+  };
+
+  /**
+   * Remove an extension from THIS converter
+   * @param {{}} extension
+   */
+  this.removeExtension = function (extension) {
+    for (var i = 0; i < langExtensions.length; ++i) {
+      if (langExtensions[i] === extension) {
+        langExtensions[i].splice(i, 1);
+        return;
+      }
+    }
+    for (var ii = 0; ii < outputModifiers.length; ++i) {
+      if (outputModifiers[ii] === extension) {
+        outputModifiers[ii].splice(i, 1);
+        return;
+      }
+    }
+  };
+
+  /**
+   * Get all extension of THIS converter
+   * @returns {{language: Array, output: Array}}
+   */
+  this.getAllExtensions = function () {
+    return {
+      language: langExtensions,
+      output: outputModifiers
+    };
+  };
+};
+
 /**
  * Turn Markdown link shortcuts into XHTML <a> tags.
  */
@@ -1317,18 +1539,6 @@ showdown.subParser('italicsAndBold', function (text) {
   return text;
 });
 
-/**
- * Run language extensions
- */
-showdown.subParser('languageExtensions', function (text, config, globals) {
-  'use strict';
-
-  showdown.helper.forEach(globals.langExtensions, function (ext) {
-    text = showdown.subParser('runExtension')(ext, text);
-  });
-  return text;
-});
-
 /**
  * Form HTML ordered (numbered) and unordered (bulleted) lists.
  */
@@ -1545,17 +1755,24 @@ showdown.subParser('paragraphs', function (text, options, globals) {
 });
 
 /**
- * Run language extensions
+ * Run extension
  */
-showdown.subParser('runExtension', function (ext, text) {
+showdown.subParser('runExtension', function (ext, text, options, globals) {
   'use strict';
 
-  if (ext.regex) {
-    var re = new RegExp(ext.regex, 'g');
-    return text.replace(re, ext.replace);
-  } else if (ext.filter) {
-    return ext.filter(text);
+  if (ext.filter) {
+    text = ext.filter(text, globals.converter, options);
+
+  } else if (ext.regex) {
+    // TODO remove this when old extension loading mechanism is deprecated
+    var re = ext.regex;
+    if (!re instanceof RegExp) {
+      re = new RegExp(re, 'g');
+    }
+    text = text.replace(re, ext.replace);
   }
+
+  return text;
 });
 
 /**

File diff suppressed because it is too large
+ 0 - 0
dist/showdown.js.map


File diff suppressed because it is too large
+ 1 - 1
dist/showdown.min.js


File diff suppressed because it is too large
+ 0 - 0
dist/showdown.min.js.map


+ 268 - 0
src/converter.js

@@ -0,0 +1,268 @@
+/**
+ * Created by Estevao on 31-05-2015.
+ */
+
+/**
+ * Showdown Converter class
+ * @class
+ * @param {object} [converterOptions]
+ * @returns {
+ *  {makeHtml: Function},
+ *  {setOption: Function},
+ *  {getOption: Function},
+ *  {getOptions: Function}
+ * }
+ */
+showdown.Converter = function (converterOptions) {
+  'use strict';
+
+  var
+      /**
+       * Options used by this converter
+       * @private
+       * @type {{}}
+       */
+      options = {
+        omitExtraWLInCodeBlocks: false,
+        prefixHeaderId:          false
+      },
+
+      /**
+       * Language extensions used by this converter
+       * @private
+       * @type {Array}
+       */
+      langExtensions = [],
+
+      /**
+       * Output modifiers extensions used by this converter
+       * @private
+       * @type {Array}
+       */
+      outputModifiers = [],
+
+      /**
+       * The parser Order
+       * @private
+       * @type {string[]}
+       */
+      parserOrder = [
+        'githubCodeBlocks',
+        'hashHTMLBlocks',
+        'stripLinkDefinitions',
+        'blockGamut',
+        'unescapeSpecialChars'
+      ];
+
+  _constructor();
+
+  /**
+   * Converter constructor
+   * @private
+   */
+  function _constructor() {
+    converterOptions = converterOptions || {};
+
+    for (var gOpt in globalOptions) {
+      if (globalOptions.hasOwnProperty(gOpt)) {
+        options[gOpt] = globalOptions[gOpt];
+      }
+    }
+
+    // Merge options
+    if (typeof converterOptions === 'object') {
+      for (var opt in converterOptions) {
+        if (converterOptions.hasOwnProperty(opt)) {
+          options[opt] = converterOptions[opt];
+        }
+      }
+    }
+
+    if (options.extensions) {
+      showdown.helper.forEach(options.extensions, _parseExtension);
+    }
+  }
+
+  /**
+   * Parse extension
+   * @param {*} ext
+   * @private
+   */
+  function _parseExtension(ext) {
+
+    // If it's a string, the extension was previously loaded
+    if (showdown.helper.isString(ext)) {
+      ext = showdown.helper.stdExtName(ext);
+
+      // TODO LEGACY SUPPORT CODE
+      if (!showdown.helper.isUndefined(showdown.extensions[ext]) && showdown.extensions[ext]) {
+        console.warn(ext + ' is an old extension that uses a deprecated loading method.' +
+          'Please inform the developer that the extension should be updated!');
+        ext = showdown.extensions[ext];
+      // END LEGACY SUPPORT CODE
+
+      } else if (!showdown.helper.isUndefined(extensions[ext])) {
+        ext = extensions[ext];
+
+      } else {
+        throw Error('Extension "' + ext + '" could not be loaded. It was either not found or is not a valid extension.');
+      }
+    } else if (typeof ext === 'function') {
+      ext = ext();
+    }
+
+    if (!showdown.validateExtension(ext)) {
+      return;
+    }
+
+    switch (ext.type) {
+      case 'lang':
+        langExtensions.push(ext);
+        break;
+
+      case 'output':
+        outputModifiers.push(ext);
+        break;
+
+      default:
+        // should never reach here
+        throw Error('Extension loader error: Type unrecognized!!!');
+    }
+  }
+
+  /**
+   * Converts a markdown string into HTML
+   * @param {string} text
+   * @returns {*}
+   */
+  this.makeHtml = function (text) {
+    //check if text is not falsy
+    if (!text) {
+      return text;
+    }
+
+    var globals = {
+      gHtmlBlocks:     [],
+      gUrls:           {},
+      gTitles:         {},
+      gListLevel:      0,
+      hashLinkCounts:  {},
+      langExtensions:  langExtensions,
+      outputModifiers: outputModifiers,
+      converter:       this
+    };
+
+    // attacklab: Replace ~ with ~T
+    // This lets us use tilde as an escape char to avoid md5 hashes
+    // The choice of character is arbitrary; anything that isn't
+    // magic in Markdown will work.
+    text = text.replace(/~/g, '~T');
+
+    // attacklab: Replace $ with ~D
+    // RegExp interprets $ as a special character
+    // when it's in a replacement string
+    text = text.replace(/\$/g, '~D');
+
+    // Standardize line endings
+    text = text.replace(/\r\n/g, '\n'); // DOS to Unix
+    text = text.replace(/\r/g, '\n'); // Mac to Unix
+
+    // Make sure text begins and ends with a couple of newlines:
+    text = '\n\n' + text + '\n\n';
+
+    // detab
+    text = showdown.subParser('detab')(text, options, globals);
+
+    // stripBlankLines
+    text = showdown.subParser('stripBlankLines')(text, options, globals);
+
+    //run languageExtensions
+    showdown.helper.forEach(langExtensions, function (ext) {
+      text = showdown.subParser('runExtension')(ext, text, options, globals);
+    });
+
+    // Run all registered parsers
+    for (var i = 0; i < parserOrder.length; ++i) {
+      var name = parserOrder[i];
+      text = parsers[name](text, options, globals);
+    }
+
+    // attacklab: Restore dollar signs
+    text = text.replace(/~D/g, '$$');
+
+    // attacklab: Restore tildes
+    text = text.replace(/~T/g, '~');
+
+    // Run output modifiers
+    showdown.helper.forEach(outputModifiers, function (ext) {
+      text = showdown.subParser('runExtension')(ext, text, options, globals);
+    });
+    text = parsers.outputModifiers(text, options, globals);
+
+    return text;
+  };
+
+  /**
+   * Set an option of this Converter instance
+   * @param {string} key
+   * @param {*} value
+   */
+  this.setOption = function (key, value) {
+    options[key] = value;
+  };
+
+  /**
+   * Get the option of this Converter instance
+   * @param {string} key
+   * @returns {*}
+   */
+  this.getOption = function (key) {
+    return options[key];
+  };
+
+  /**
+   * Get the options of this Converter instance
+   * @returns {{}}
+   */
+  this.getOptions = function () {
+    return options;
+  };
+
+  /**
+   * Add extension to THIS converter
+   * @param {{}} extension
+   */
+  this.addExtension = function (extension) {
+    _parseExtension(extension);
+  };
+
+  /**
+   * Remove an extension from THIS converter
+   * @param {{}} extension
+   */
+  this.removeExtension = function (extension) {
+    for (var i = 0; i < langExtensions.length; ++i) {
+      if (langExtensions[i] === extension) {
+        langExtensions[i].splice(i, 1);
+        return;
+      }
+    }
+    for (var ii = 0; ii < outputModifiers.length; ++i) {
+      if (outputModifiers[ii] === extension) {
+        outputModifiers[ii].splice(i, 1);
+        return;
+      }
+    }
+  };
+
+  /**
+   * Get all extension of THIS converter
+   * @returns {{language: Array, output: Array}}
+   */
+  this.getAllExtensions = function () {
+    return {
+      language: langExtensions,
+      output: outputModifiers
+    };
+  };
+};

+ 131 - 194
src/showdown.js

@@ -6,10 +6,11 @@
 var showdown = {},
     parsers = {},
     extensions = {},
-    globalOptions = {
+    defaultOptions = {
       omitExtraWLInCodeBlocks: false,
       prefixHeaderId:          false
-    };
+    },
+    globalOptions = JSON.parse(JSON.stringify(defaultOptions)); //clone default options out of laziness =P
 
 /**
  * helper namespace
@@ -17,7 +18,10 @@ var showdown = {},
  */
 showdown.helper = {};
 
-// Public properties
+/**
+ * TODO LEGACY SUPPORT CODE
+ * @type {{}}
+ */
 showdown.extensions = {};
 
 /**
@@ -54,6 +58,11 @@ showdown.getOptions = function () {
   return globalOptions;
 };
 
+showdown.resetOptions = function () {
+  'use strict';
+  globalOptions = JSON.parse(JSON.stringify(defaultOptions));
+};
+
 /**
  * Get or set a subParser
  *
@@ -79,6 +88,13 @@ showdown.subParser = function (name, func) {
   }
 };
 
+/**
+ * Gets or registers an extension
+ * @static
+ * @param {string} name
+ * @param {object|function=} ext
+ * @returns {*}
+ */
 showdown.extension = function (name, ext) {
   'use strict';
 
@@ -88,228 +104,149 @@ showdown.extension = function (name, ext) {
 
   name = showdown.helper.stdExtName(name);
 
+  // Getter
   if (showdown.helper.isUndefined(ext)) {
-    return getExtension();
+    if (!extensions.hasOwnProperty(name)) {
+      throw Error('Extension named ' + name + ' is not registered!');
+    }
+    return extensions[name];
+
+    // Setter
   } else {
-    return setExtension();
-  }
-};
+    if (typeof ext === 'function') {
+      ext = ext();
+    }
 
-function getExtension(name) {
-  'use strict';
+    var validExtension = validate(ext, name);
 
-  if (!extensions.hasOwnProperty(name)) {
-    throw Error('Extension named ' + name + ' is not registered!');
+    if (validExtension.valid) {
+      extensions[name] = ext;
+    } else {
+      throw Error(validExtension.error);
+    }
   }
-  return extensions[name];
-}
+};
 
-function setExtension(name, ext) {
+/**
+ * Gets all extensions registered
+ * @returns {{}}
+ */
+showdown.getAllExtensions = function () {
   'use strict';
+  return extensions;
+};
 
-  if (typeof ext !== 'object') {
-    throw Error('A Showdown Extension must be an object, ' + typeof ext + ' given');
-  }
-
-  if (!showdown.helper.isString(ext.type)) {
-    throw Error('When registering a showdown extension, "type" must be a string, ' + typeof ext.type + ' given');
-  }
-
-  ext.type = ext.type.toLowerCase();
+/**
+ * Remove an extension
+ * @param {string} name
+ */
+showdown.removeExtension = function (name) {
+  'use strict';
+  delete extensions[name];
+};
 
-  extensions[name] = ext;
-}
+/**
+ * Removes all extensions
+ */
+showdown.resetExtensions = function () {
+  'use strict';
+  extensions = {};
+};
 
 /**
- * Showdown Converter class
- *
- * @param {object} [converterOptions]
- * @returns {{makeHtml: Function}}
+ * Validate extension
+ * @param {object} ext
+ * @param {string} name
+ * @returns {{valid: boolean, error: string}}
  */
-showdown.Converter = function (converterOptions) {
+function validate(ext, name) {
   'use strict';
 
-  converterOptions = converterOptions || {};
-
-  var options = {},
-      langExtensions = [],
-      outputModifiers = [],
-      parserOrder = [
-        'githubCodeBlocks',
-        'hashHTMLBlocks',
-        'stripLinkDefinitions',
-        'blockGamut',
-        'unescapeSpecialChars'
-      ];
-
-  for (var gOpt in globalOptions) {
-    if (globalOptions.hasOwnProperty(gOpt)) {
-      options[gOpt] = globalOptions[gOpt];
-    }
-  }
+  var baseMsg = (name) ? 'Error in ' + name + ' extension: ' : 'Error in unnamed extension',
+    ret = {
+      valid: true,
+      error: baseMsg
+    };
 
-  // Merge options
-  if (typeof converterOptions === 'object') {
-    for (var opt in converterOptions) {
-      if (converterOptions.hasOwnProperty(opt)) {
-        options[opt] = converterOptions[opt];
-      }
-    }
+  if (typeof ext !== 'object') {
+    ret.valid = false;
+    ret.error = baseMsg + 'it must be an object, but ' + typeof ext + ' given';
+    return ret;
   }
 
-  // This is a dirty workaround to maintain backwards extension compatibility
-  // We define a self var (which is a copy of this) and inject the makeHtml function
-  // directly to it. This ensures a full converter object is available when iterating over extensions
-  // We should rewrite the extension loading mechanism and use some kind of interface or decorator pattern
-  // and inject the object reference there instead.
-  var self = this;
-  self.makeHtml = makeHtml;
-
-  // Parse options
-  if (options.extensions) {
-
-    // Iterate over each plugin
-    showdown.helper.forEach(options.extensions, function (plugin) {
-      var pluginName = plugin;
-
-      // Assume it's a bundled plugin if a string is given
-      if (typeof plugin === 'string') {
-        var tPluginName = showdown.helper.stdExtName(plugin);
+  if (!showdown.helper.isString(ext.type)) {
+    ret.valid = false;
+    ret.error = baseMsg + 'property "type" must be a string, but ' + typeof ext.type + ' given';
+    return ret;
+  }
 
-        if (!showdown.helper.isUndefined(showdown.extensions[tPluginName]) && showdown.extensions[tPluginName]) {
-          //Trigger some kind of deprecated alert
-          plugin = showdown.extensions[tPluginName];
+  var type = ext.type = ext.type.toLowerCase();
 
-        } else if (!showdown.helper.isUndefined(extensions[tPluginName])) {
-          plugin = extensions[tPluginName];
-        }
-      }
+  // normalize extension type
+  if (type === 'language') {
+    type = ext.type = 'lang';
+  }
 
-      if (typeof plugin === 'function') {
-        // Iterate over each extension within that plugin
-        showdown.helper.forEach(plugin(self), function (ext) {
-          // Sort extensions by type
-          if (ext.type) {
-            if (ext.type === 'language' || ext.type === 'lang') {
-              langExtensions.push(ext);
-            } else if (ext.type === 'output' || ext.type === 'html') {
-              outputModifiers.push(ext);
-            }
-          } else {
-            // Assume language extension
-            outputModifiers.push(ext);
-          }
-        });
-      } else {
-        var errMsg = 'An extension could not be loaded. It was either not found or is not a valid extension.';
-        if (typeof pluginName === 'string') {
-          errMsg = 'Extension "' + pluginName + '" could not be loaded.  It was either not found or is not a valid extension.';
-        }
-        throw errMsg;
-      }
-    });
+  if (type === 'html') {
+    type = ext.type = 'output';
   }
 
-  /**
-   * Converts a markdown string into HTML
-   * @param {string} text
-   * @returns {*}
-   */
-  function makeHtml(text) {
+  if (type !== 'lang' && type !== 'output') {
+    ret.valid = false;
+    ret.error = baseMsg + 'type ' + type + ' is not recognized. Valid values: "lang" or "output"';
+    return ret;
+  }
 
-    //check if text is not falsy
-    if (!text) {
-      return text;
+  if (ext.filter) {
+    if (typeof ext.filter !== 'function') {
+      ret.valid = false;
+      ret.error = baseMsg + '"filter" must be a function, but ' + typeof ext.filter + ' given';
+      return ret;
     }
 
-    var globals = {
-      gHtmlBlocks:     [],
-      gUrls:           {},
-      gTitles:         {},
-      gListLevel:      0,
-      hashLinkCounts:  {},
-      langExtensions:  langExtensions,
-      outputModifiers: outputModifiers
-    };
-
-    // attacklab: Replace ~ with ~T
-    // This lets us use tilde as an escape char to avoid md5 hashes
-    // The choice of character is arbitrary; anything that isn't
-    // magic in Markdown will work.
-    text = text.replace(/~/g, '~T');
-
-    // attacklab: Replace $ with ~D
-    // RegExp interprets $ as a special character
-    // when it's in a replacement string
-    text = text.replace(/\$/g, '~D');
-
-    // Standardize line endings
-    text = text.replace(/\r\n/g, '\n'); // DOS to Unix
-    text = text.replace(/\r/g, '\n'); // Mac to Unix
-
-    // Make sure text begins and ends with a couple of newlines:
-    text = '\n\n' + text + '\n\n';
-
-    // detab
-    text = parsers.detab(text, options, globals);
-
-    // stripBlankLines
-    text = parsers.stripBlankLines(text, options, globals);
-
-    //run languageExtensions
-    text = parsers.languageExtensions(text, options, globals);
-
-    // Run all registered parsers
-    for (var i = 0; i < parserOrder.length; ++i) {
-      var name = parserOrder[i];
-      text = parsers[name](text, options, globals);
+  } else if (ext.regex) {
+    if (showdown.helper.isString(ext.regex)) {
+      ext.regex = new RegExp(ext.regex, 'g');
+    }
+    if (!ext.regex instanceof RegExp) {
+      ret.valid = false;
+      ret.error = baseMsg + '"regex" property must either be a string or a RegExp object, but ' +
+        typeof ext.regex + ' given';
+      return ret;
+    }
+    if (showdown.helper.isUndefined(ext.replace)) {
+      ret.valid = false;
+      ret.error = baseMsg + '"regex" extensions must implement a replace string or function';
+      return ret;
     }
 
-    // attacklab: Restore dollar signs
-    text = text.replace(/~D/g, '$$');
-
-    // attacklab: Restore tildes
-    text = text.replace(/~T/g, '~');
-
-    // Run output modifiers
-    showdown.helper.forEach(globals.outputModifiers, function (ext) {
-      text = showdown.subParser('runExtension')(ext, text);
-    });
-    text = parsers.outputModifiers(text, options, globals);
-
-    return text;
+  } else {
+    ret.valid = false;
+    ret.error = baseMsg + 'extensions must define either a "regex" property or a "filter" method';
+    return ret;
   }
 
-  /**
-   * Set an option of this Converter instance
-   * @param {string} key
-   * @param {*} value
-   */
-  function setOption (key, value) {
-    options[key] = value;
+  if (showdown.helper.isUndefined(ext.filter) && showdown.helper.isUndefined(ext.regex)) {
+    ret.valid = false;
+    ret.error = baseMsg + 'output extensions must define a filter property';
+    return ret;
   }
 
-  /**
-   * Get the option of this Converter instance
-   * @param {string} key
-   * @returns {*}
-   */
-  function getOption(key) {
-    return options[key];
-  }
+  return ret;
+}
 
-  /**
-   * Get the options of this Converter instance
-   * @returns {{}}
-   */
-  function getOptions() {
-    return options;
-  }
+/**
+ * Validate extension
+ * @param {object} ext
+ * @returns {boolean}
+ */
+showdown.validateExtension = function (ext) {
+  'use strict';
 
-  return {
-    makeHtml: makeHtml,
-    setOption: setOption,
-    getOption: getOption,
-    getOptions: getOptions
-  };
+  var validateExtension = validate(ext, null);
+  if (!validateExtension.valid) {
+    console.warn(validateExtension.error);
+    return false;
+  }
+  return true;
 };

+ 0 - 11
src/subParsers/languageExtensions.js

@@ -1,11 +0,0 @@
-/**
- * Run language extensions
- */
-showdown.subParser('languageExtensions', function (text, config, globals) {
-  'use strict';
-
-  showdown.helper.forEach(globals.langExtensions, function (ext) {
-    text = showdown.subParser('runExtension')(ext, text);
-  });
-  return text;
-});

+ 14 - 7
src/subParsers/runExtension.js

@@ -1,13 +1,20 @@
 /**
- * Run language extensions
+ * Run extension
  */
-showdown.subParser('runExtension', function (ext, text) {
+showdown.subParser('runExtension', function (ext, text, options, globals) {
   'use strict';
 
-  if (ext.regex) {
-    var re = new RegExp(ext.regex, 'g');
-    return text.replace(re, ext.replace);
-  } else if (ext.filter) {
-    return ext.filter(text);
+  if (ext.filter) {
+    text = ext.filter(text, globals.converter, options);
+
+  } else if (ext.regex) {
+    // TODO remove this when old extension loading mechanism is deprecated
+    var re = ext.regex;
+    if (!re instanceof RegExp) {
+      re = new RegExp(re, 'g');
+    }
+    text = text.replace(re, ext.replace);
   }
+
+  return text;
 });

+ 61 - 0
test/node/showdown.Converter.js

@@ -0,0 +1,61 @@
+/**
+ * Created by Estevao on 31-05-2015.
+ */
+require('source-map-support').install();
+require('chai').should();
+require('sinon');
+var showdown = require('../../dist/showdown.js');
+
+describe('showdown.Converter', function () {
+  'use strict';
+
+  describe('option methods', function () {
+    var converter = new showdown.Converter();
+
+    it('setOption() should set option foo=baz', function () {
+      converter.setOption('foo', 'baz');
+    });
+
+    it('getOption() should get option foo to equal baz', function () {
+      converter.getOption('foo').should.equal('baz');
+    });
+
+    it('getOptions() should contain foo=baz', function () {
+      var options = converter.getOptions();
+      options.should.have.ownProperty('foo');
+      options.foo.should.equal('baz');
+    });
+  });
+
+  describe('extension methods', function () {
+    var extObjMock = {
+          type: 'lang',
+          filter: function () {}
+        },
+        extObjFunc = function () {
+          return extObjMock;
+        };
+
+    it('addExtension() should add an extension Object', function () {
+      var converter = new showdown.Converter();
+      converter.addExtension(extObjMock);
+      converter.getAllExtensions().language.should.contain(extObjMock);
+    });
+
+    it('addExtension() should unwrap an extension wrapped in a function', function () {
+      var converter = new showdown.Converter();
+
+      converter.addExtension(extObjFunc);
+      converter.getAllExtensions().language.should.contain(extObjMock);
+    });
+
+    it('addExtension() should add a previous registered extension in showdown', function () {
+      showdown.extension('foo', extObjMock);
+      var converter = new showdown.Converter();
+
+      converter.addExtension('foo');
+      converter.getAllExtensions().language.should.contain(extObjMock);
+      showdown.resetExtensions();
+    });
+  });
+});

+ 83 - 0
test/node/showdown.js

@@ -0,0 +1,83 @@
+require('source-map-support').install();
+require('chai').should();
+var expect = require('chai').expect,
+    showdown = require('../../dist/showdown.js');
+
+describe('showdown.options', function () {
+  'use strict';
+
+  describe('setOption() and getOption()', function () {
+    it('should set option foo=bar', function () {
+      showdown.setOption('foo', 'bar');
+      showdown.getOption('foo').should.equal('bar');
+      showdown.resetOptions();
+      expect(showdown.getOption('foo')).to.be.undefined();
+    });
+  });
+});
+
+describe('showdown.extension', function () {
+  'use strict';
+
+  var extObjMock = {
+        type: 'lang',
+        filter: function () {}
+      },
+      extObjFunc = function () {
+        return extObjMock;
+      };
+
+  describe('should register', function () {
+    it('should register an extension object', function () {
+      showdown.extension('foo', extObjMock);
+      showdown.extension('foo').should.equal(extObjMock);
+      showdown.resetExtensions();
+    });
+
+    it('should register an extension function', function () {
+      showdown.extension('foo', extObjFunc);
+      showdown.extension('foo').should.equal(extObjMock);
+      showdown.resetExtensions();
+    });
+  });
+
+  describe('should refuse to register', function () {
+    it('a generic object', function () {
+      var fn = function () {
+        showdown.extension('foo', {});
+      };
+      expect(fn).to.throw();
+    });
+
+    it('an extension with invalid type', function () {
+      var fn = function () {
+        showdown.extension('foo', {
+          type: 'foo'
+        });
+      };
+      expect(fn).to.throw(/type .+? is not recognized\. Valid values: "lang" or "output"/);
+    });
+
+    it('an extension without regex or filter', function () {
+      var fn = function () {
+        showdown.extension('foo', {
+          type: 'lang'
+        });
+      };
+      expect(fn).to.throw(/extensions must define either a "regex" property or a "filter" method/);
+    });
+  });
+});
+
+describe('showdown.getAllExtensions()', function () {
+  'use strict';
+  var extObjMock = {
+        type: 'lang',
+        filter: function () {}
+      };
+
+  it('should return all extensions', function () {
+    showdown.extension('bar', extObjMock);
+    showdown.getAllExtensions().should.eql({bar: extObjMock});
+  });
+});

Some files were not shown because too many files changed in this diff