mirror of
https://github.com/showdownjs/showdown.git
synced 2024-03-22 13:30:55 +08:00
603 lines
16 KiB
JavaScript
603 lines
16 KiB
JavaScript
/**
|
|
* Created by Estevao on 31-05-2015.
|
|
*/
|
|
|
|
/**
|
|
* Showdown Converter class
|
|
* @class
|
|
* @param {object} [converterOptions]
|
|
* @returns {Converter}
|
|
*/
|
|
showdown.Converter = function (converterOptions) {
|
|
'use strict';
|
|
|
|
var
|
|
/**
|
|
* Options used by this converter
|
|
* @private
|
|
* @type {{}}
|
|
*/
|
|
options = {},
|
|
|
|
/**
|
|
* Language extensions used by this converter
|
|
* @private
|
|
* @type {Array}
|
|
*/
|
|
langExtensions = [],
|
|
|
|
/**
|
|
* Output modifiers extensions used by this converter
|
|
* @private
|
|
* @type {Array}
|
|
*/
|
|
outputModifiers = [],
|
|
|
|
/**
|
|
* Event listeners
|
|
* @private
|
|
* @type {{}}
|
|
*/
|
|
listeners = {},
|
|
|
|
/**
|
|
* The flavor set in this converter
|
|
*/
|
|
setConvFlavor = setFlavor,
|
|
|
|
/**
|
|
* Metadata of the document
|
|
* @type {{parsed: {}, raw: string, format: string}}
|
|
*/
|
|
metadata = {
|
|
parsed: {},
|
|
raw: '',
|
|
format: ''
|
|
};
|
|
|
|
_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];
|
|
}
|
|
}
|
|
} else {
|
|
throw Error('Converter expects the passed parameter to be an object, but ' + typeof converterOptions +
|
|
' was passed instead.');
|
|
}
|
|
|
|
if (options.extensions) {
|
|
showdown.helper.forEach(options.extensions, _parseExtension);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse extension
|
|
* @param {*} ext
|
|
* @param {string} [name='']
|
|
* @private
|
|
*/
|
|
function _parseExtension (ext, name) {
|
|
|
|
name = name || null;
|
|
// If it's a string, the extension was previously loaded
|
|
if (showdown.helper.isString(ext)) {
|
|
ext = showdown.helper.stdExtName(ext);
|
|
name = ext;
|
|
|
|
// LEGACY_SUPPORT CODE
|
|
if (showdown.extensions[ext]) {
|
|
console.warn('DEPRECATION WARNING: ' + ext + ' is an old extension that uses a deprecated loading method.' +
|
|
'Please inform the developer that the extension should be updated!');
|
|
legacyExtensionLoading(showdown.extensions[ext], ext);
|
|
return;
|
|
// 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.');
|
|
}
|
|
}
|
|
|
|
if (typeof ext === 'function') {
|
|
ext = ext();
|
|
}
|
|
|
|
if (!showdown.helper.isArray(ext)) {
|
|
ext = [ext];
|
|
}
|
|
|
|
var validExt = validate(ext, name);
|
|
if (!validExt.valid) {
|
|
throw Error(validExt.error);
|
|
}
|
|
|
|
for (var i = 0; i < ext.length; ++i) {
|
|
switch (ext[i].type) {
|
|
|
|
case 'lang':
|
|
langExtensions.push(ext[i]);
|
|
break;
|
|
|
|
case 'output':
|
|
outputModifiers.push(ext[i]);
|
|
break;
|
|
}
|
|
if (ext[i].hasOwnProperty('listeners')) {
|
|
for (var ln in ext[i].listeners) {
|
|
if (ext[i].listeners.hasOwnProperty(ln)) {
|
|
listen(ln, ext[i].listeners[ln]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
* LEGACY_SUPPORT
|
|
* @param {*} ext
|
|
* @param {string} name
|
|
*/
|
|
function legacyExtensionLoading (ext, name) {
|
|
if (typeof ext === 'function') {
|
|
ext = ext(new showdown.Converter());
|
|
}
|
|
if (!showdown.helper.isArray(ext)) {
|
|
ext = [ext];
|
|
}
|
|
var valid = validate(ext, name);
|
|
|
|
if (!valid.valid) {
|
|
throw Error(valid.error);
|
|
}
|
|
|
|
for (var i = 0; i < ext.length; ++i) {
|
|
switch (ext[i].type) {
|
|
case 'lang':
|
|
langExtensions.push(ext[i]);
|
|
break;
|
|
case 'output':
|
|
outputModifiers.push(ext[i]);
|
|
break;
|
|
default:// should never reach here
|
|
throw Error('Extension loader error: Type unrecognized!!!');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Listen to an event
|
|
* @param {string} name
|
|
* @param {function} callback
|
|
*/
|
|
function listen (name, callback) {
|
|
if (!showdown.helper.isString(name)) {
|
|
throw Error('Invalid argument in converter.listen() method: name must be a string, but ' + typeof name + ' given');
|
|
}
|
|
|
|
if (typeof callback !== 'function') {
|
|
throw Error('Invalid argument in converter.listen() method: callback must be a function, but ' + typeof callback + ' given');
|
|
}
|
|
name = name.toLowerCase();
|
|
if (!listeners.hasOwnProperty(name)) {
|
|
listeners[name] = [];
|
|
}
|
|
listeners[name].push(callback);
|
|
}
|
|
|
|
function rTrimInputText (text) {
|
|
var rsp = text.match(/^\s*/)[0].length,
|
|
rgx = new RegExp('^\\s{0,' + rsp + '}', 'gm');
|
|
return text.replace(rgx, '');
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {string} evtName Event name
|
|
* @param {string} text Text
|
|
* @param {{}} options Converter Options
|
|
* @param {{}} globals Converter globals
|
|
* @param {{}} pParams extra params for event
|
|
* @returns showdown.helper.Event
|
|
* @private
|
|
*/
|
|
this._dispatch = function dispatch (evtName, text, options, globals, pParams) {
|
|
evtName = evtName.toLowerCase();
|
|
var params = pParams || {};
|
|
params.converter = this;
|
|
params.text = text;
|
|
params.options = options;
|
|
params.globals = globals;
|
|
var event = new showdown.helper.Event(evtName, text, params);
|
|
|
|
if (listeners.hasOwnProperty(evtName)) {
|
|
for (var ei = 0; ei < listeners[evtName].length; ++ei) {
|
|
var nText = listeners[evtName][ei](event);
|
|
if (nText && typeof nText !== 'undefined') {
|
|
event.setText(nText);
|
|
}
|
|
}
|
|
}
|
|
return event;
|
|
};
|
|
|
|
/**
|
|
* Listen to an event
|
|
* @param {string} name
|
|
* @param {function} callback
|
|
* @returns {showdown.Converter}
|
|
*/
|
|
this.listen = function (name, callback) {
|
|
listen(name, callback);
|
|
return this;
|
|
};
|
|
|
|
/**
|
|
* Converts a markdown string into HTML string
|
|
* @param {string} text
|
|
* @returns {*}
|
|
*/
|
|
this.makeHtml = function (text) {
|
|
//check if text is not falsy
|
|
if (!text) {
|
|
return text;
|
|
}
|
|
|
|
var globals = {
|
|
gHtmlBlocks: [],
|
|
gHtmlMdBlocks: [],
|
|
gHtmlSpans: [],
|
|
gUrls: {},
|
|
gTitles: {},
|
|
gDimensions: {},
|
|
gListLevel: 0,
|
|
hashLinkCounts: {},
|
|
langExtensions: langExtensions,
|
|
outputModifiers: outputModifiers,
|
|
converter: this,
|
|
ghCodeBlocks: [],
|
|
metadata: {
|
|
parsed: {},
|
|
raw: '',
|
|
format: ''
|
|
}
|
|
};
|
|
|
|
// This lets us use ¨ trema 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');
|
|
|
|
// 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
|
|
|
|
// Stardardize line spaces
|
|
text = text.replace(/\u00A0/g, ' ');
|
|
|
|
if (options.smartIndentationFix) {
|
|
text = rTrimInputText(text);
|
|
}
|
|
|
|
// Make sure text begins and ends with a couple of newlines:
|
|
text = '\n\n' + text + '\n\n';
|
|
|
|
// detab
|
|
text = showdown.subParser('makehtml.detab')(text, options, globals);
|
|
|
|
/**
|
|
* Strip any lines consisting only of spaces and tabs.
|
|
* This makes subsequent regexs easier to write, because we can
|
|
* match consecutive blank lines with /\n+/ instead of something
|
|
* contorted like /[ \t]*\n+/
|
|
*/
|
|
text = text.replace(/^[ \t]+$/mg, '');
|
|
|
|
//run languageExtensions
|
|
showdown.helper.forEach(langExtensions, function (ext) {
|
|
text = showdown.subParser('makehtml.runExtension')(ext, text, options, globals);
|
|
});
|
|
|
|
// run the sub parsers
|
|
text = showdown.subParser('makehtml.metadata')(text, options, globals);
|
|
text = showdown.subParser('makehtml.hashPreCodeTags')(text, options, globals);
|
|
text = showdown.subParser('makehtml.githubCodeBlocks')(text, options, globals);
|
|
text = showdown.subParser('makehtml.hashHTMLBlocks')(text, options, globals);
|
|
text = showdown.subParser('makehtml.hashCodeTags')(text, options, globals);
|
|
text = showdown.subParser('makehtml.stripLinkDefinitions')(text, options, globals);
|
|
text = showdown.subParser('makehtml.blockGamut')(text, options, globals);
|
|
text = showdown.subParser('makehtml.unhashHTMLSpans')(text, options, globals);
|
|
text = showdown.subParser('makehtml.unescapeSpecialChars')(text, options, globals);
|
|
|
|
// attacklab: Restore dollar signs
|
|
text = text.replace(/¨D/g, '$$');
|
|
|
|
// attacklab: Restore tremas
|
|
text = text.replace(/¨T/g, '¨');
|
|
|
|
// render a complete html document instead of a partial if the option is enabled
|
|
text = showdown.subParser('makehtml.completeHTMLDocument')(text, options, globals);
|
|
|
|
// Run output modifiers
|
|
showdown.helper.forEach(outputModifiers, function (ext) {
|
|
text = showdown.subParser('makehtml.runExtension')(ext, text, options, globals);
|
|
});
|
|
|
|
// update metadata
|
|
metadata = globals.metadata;
|
|
return text;
|
|
};
|
|
|
|
/**
|
|
* Converts an HTML string into a markdown string
|
|
* @param src
|
|
* @returns {string}
|
|
*/
|
|
this.makeMarkdown = function (src) {
|
|
|
|
// replace \r\n with \n
|
|
src = src.replace(/\r\n/g, '\n');
|
|
src = src.replace(/\r/g, '\n'); // old macs
|
|
|
|
// due to an edge case, we need to find this: > <
|
|
// to prevent removing of non silent white spaces
|
|
// ex: <em>this is</em> <strong>sparta</strong>
|
|
src = src.replace(/>[ \t]+</, '>¨NBSP;<');
|
|
|
|
var doc = showdown.helper.document.createElement('div');
|
|
doc.innerHTML = src;
|
|
|
|
var globals = {
|
|
preList: substitutePreCodeTags(doc)
|
|
};
|
|
|
|
// remove all newlines and collapse spaces
|
|
clean(doc);
|
|
|
|
// some stuff, like accidental reference links must now be escaped
|
|
// TODO
|
|
// doc.innerHTML = doc.innerHTML.replace(/\[[\S\t ]]/);
|
|
|
|
var nodes = doc.childNodes,
|
|
mdDoc = '';
|
|
|
|
for (var i = 0; i < nodes.length; i++) {
|
|
mdDoc += showdown.subParser('makeMarkdown.node')(nodes[i], globals);
|
|
}
|
|
|
|
function clean (node) {
|
|
for (var n = 0; n < node.childNodes.length; ++n) {
|
|
var child = node.childNodes[n];
|
|
if (child.nodeType === 3) {
|
|
if (!/\S/.test(child.nodeValue)) {
|
|
node.removeChild(child);
|
|
--n;
|
|
} else {
|
|
child.nodeValue = child.nodeValue.split('\n').join(' ');
|
|
child.nodeValue = child.nodeValue.replace(/(\s)+/g, '$1');
|
|
}
|
|
} else if (child.nodeType === 1) {
|
|
clean(child);
|
|
}
|
|
}
|
|
}
|
|
|
|
// find all pre tags and replace contents with placeholder
|
|
// we need this so that we can remove all indentation from html
|
|
// to ease up parsing
|
|
function substitutePreCodeTags (doc) {
|
|
|
|
var pres = doc.querySelectorAll('pre'),
|
|
presPH = [];
|
|
|
|
for (var i = 0; i < pres.length; ++i) {
|
|
|
|
if (pres[i].childElementCount === 1 && pres[i].firstChild.tagName.toLowerCase() === 'code') {
|
|
var content = pres[i].firstChild.innerHTML.trim(),
|
|
language = pres[i].firstChild.getAttribute('data-language') || '';
|
|
|
|
// if data-language attribute is not defined, then we look for class language-*
|
|
if (language === '') {
|
|
var classes = pres[i].firstChild.className.split(' ');
|
|
for (var c = 0; c < classes.length; ++c) {
|
|
var matches = classes[c].match(/^language-(.+)$/);
|
|
if (matches !== null) {
|
|
language = matches[1];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// unescape html entities in content
|
|
content = showdown.helper.unescapeHTMLEntities(content);
|
|
|
|
presPH.push(content);
|
|
pres[i].outerHTML = '<precode language="' + language + '" precodenum="' + i.toString() + '"></precode>';
|
|
} else {
|
|
presPH.push(pres[i].innerHTML);
|
|
pres[i].innerHTML = '';
|
|
pres[i].setAttribute('prenum', i.toString());
|
|
}
|
|
}
|
|
return presPH;
|
|
}
|
|
|
|
return mdDoc;
|
|
};
|
|
|
|
/**
|
|
* 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
|
|
* @param {string} [name=null]
|
|
*/
|
|
this.addExtension = function (extension, name) {
|
|
name = name || null;
|
|
_parseExtension(extension, name);
|
|
};
|
|
|
|
/**
|
|
* Use a global registered extension with THIS converter
|
|
* @param {string} extensionName Name of the previously registered extension
|
|
*/
|
|
this.useExtension = function (extensionName) {
|
|
_parseExtension(extensionName);
|
|
};
|
|
|
|
/**
|
|
* Set the flavor THIS converter should use
|
|
* @param {string} name
|
|
*/
|
|
this.setFlavor = function (name) {
|
|
if (!flavor.hasOwnProperty(name)) {
|
|
throw Error(name + ' flavor was not found');
|
|
}
|
|
var preset = flavor[name];
|
|
setConvFlavor = name;
|
|
for (var option in preset) {
|
|
if (preset.hasOwnProperty(option)) {
|
|
options[option] = preset[option];
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get the currently set flavor of this converter
|
|
* @returns {string}
|
|
*/
|
|
this.getFlavor = function () {
|
|
return setConvFlavor;
|
|
};
|
|
|
|
/**
|
|
* Remove an extension from THIS converter.
|
|
* Note: This is a costly operation. It's better to initialize a new converter
|
|
* and specify the extensions you wish to use
|
|
* @param {Array} extension
|
|
*/
|
|
this.removeExtension = function (extension) {
|
|
if (!showdown.helper.isArray(extension)) {
|
|
extension = [extension];
|
|
}
|
|
for (var a = 0; a < extension.length; ++a) {
|
|
var ext = extension[a];
|
|
for (var i = 0; i < langExtensions.length; ++i) {
|
|
if (langExtensions[i] === ext) {
|
|
langExtensions[i].splice(i, 1);
|
|
}
|
|
}
|
|
for (var ii = 0; ii < outputModifiers.length; ++i) {
|
|
if (outputModifiers[ii] === ext) {
|
|
outputModifiers[ii].splice(i, 1);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get all extension of THIS converter
|
|
* @returns {{language: Array, output: Array}}
|
|
*/
|
|
this.getAllExtensions = function () {
|
|
return {
|
|
language: langExtensions,
|
|
output: outputModifiers
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Get the metadata of the previously parsed document
|
|
* @param raw
|
|
* @returns {string|{}}
|
|
*/
|
|
this.getMetadata = function (raw) {
|
|
if (raw) {
|
|
return metadata.raw;
|
|
} else {
|
|
return metadata.parsed;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get the metadata format of the previously parsed document
|
|
* @returns {string}
|
|
*/
|
|
this.getMetadataFormat = function () {
|
|
return metadata.format;
|
|
};
|
|
|
|
/**
|
|
* Private: set a single key, value metadata pair
|
|
* @param {string} key
|
|
* @param {string} value
|
|
*/
|
|
this._setMetadataPair = function (key, value) {
|
|
metadata.parsed[key] = value;
|
|
};
|
|
|
|
/**
|
|
* Private: set metadata format
|
|
* @param {string} format
|
|
*/
|
|
this._setMetadataFormat = function (format) {
|
|
metadata.format = format;
|
|
};
|
|
|
|
/**
|
|
* Private: set metadata raw text
|
|
* @param {string} raw
|
|
*/
|
|
this._setMetadataRaw = function (raw) {
|
|
metadata.raw = raw;
|
|
};
|
|
};
|