Browse Source

Merge remote-tracking branch 'tstone/syntax-extensions'

Corey Innis 12 years ago
parent
commit
c0d6f9fb21

+ 100 - 0
README.md

@@ -104,6 +104,34 @@ Showdown has been tested successfully with:
 In theory, Showdown will work in any browser that supports ECMA 262 3rd Edition (JavaScript 1.5).  The converter itself might even work in things that aren't web browsers, like Acrobat.  No promises.
 In theory, Showdown will work in any browser that supports ECMA 262 3rd Edition (JavaScript 1.5).  The converter itself might even work in things that aren't web browsers, like Acrobat.  No promises.
 
 
 
 
+Extensions
+----------
+
+Showdown allows additional functionality to be loaded via extensions.
+
+### Client-side Extension Usage
+
+```js
+<script src="src/showdown.js" />
+<script src="src/extensions/twitter.js" />
+
+var converter = new Showdown().converter({ extensions: 'twitter' });
+```
+
+### Server-side Extension Usage
+
+```js
+// Using a bundled extension
+var Showdown = require('showdown');
+var converter = new Showdown().converter({ extensions: ['twitter'] });
+
+// Using a custom extension
+var mine = require('./custom-extensions/mine');
+var converter = new Showdown().converter({ extensions: ['twitter', mine] });
+```
+
+
+
 Known Differences in Output
 Known Differences in Output
 ---------------------------
 ---------------------------
 
 
@@ -203,6 +231,78 @@ Once installed the tests can be run from the project root using:
 New test cases can easily be added.  Create a markdown file (ending in `.md`) which contains the markdown to test.  Create a `.html` file of the exact same name.  It will automatically be tested when the tests are executed with `mocha`.
 New test cases can easily be added.  Create a markdown file (ending in `.md`) which contains the markdown to test.  Create a `.html` file of the exact same name.  It will automatically be tested when the tests are executed with `mocha`.
 
 
 
 
+Creating Markdown Extensions
+----------------------------
+
+A showdown extension is simply a function which returns an array of extensions.  Each single extension can be one of two types:
+
+  - Language Extension -- Language extensions are ones that that add new markdown syntax to showdown.  For example, say you wanted `^^youtube http://www.youtube.com/watch?v=oHg5SJYRHA0` to automatically render as an embedded YouTube video, that would be a language extension.
+  - Output Modifiers -- After showdown has run, and generated HTML, an output modifier would change that HTML.  For example, say you wanted to change `<div class="header">` to be `<header>`, that would be an output modifier.
+
+Each extension can provide two combinations of interfaces for showdown.
+
+#### Regex/Replace
+
+Regex/replace style extensions are very similar to javascripts `string.replace` function.  Two properties are given, `regex` and `replace`.  `regex` is a string and `replace` can be either a string or a function.  If `replace` is a string, it can use the `$1` syntax for group substituation, exactly as if it were making use of `string.replace` (internally it does this actually);  The value of `regex` is assumed to be a global replacement.
+
+#### Regex/Replace Example
+
+``` js
+var demo = function(converter) {
+  return [
+    // Replace escaped @ symbols
+    { type: 'lang', regex: '\\@', replace: '@' }
+  ];
+}
+```
+
+#### Filter
+
+Alternately, if you'd just like to do everything yourself, you can specify a filter which is a callback with a single input parameter, text (the current source text within the showdown engine).
+
+#### Filter Example
+
+``` js
+var demo = function(converter) {
+  return [
+    // Replace escaped @ symbols
+    { type: 'lang', function(text) {
+      return text.replace(/\\@/g, '@');
+    }}
+  ];
+}
+```
+
+#### Implementation Concerns
+
+One bit which should be taken into account is maintaining both client-side and server-side compatibility.  This can be achieved with a few lines of boilerplate code.  First, to prevent polluting the global scope for client-side code, the extension definition should be wrapped in a self executing function.
+
+``` js
+(function(){
+  // Your extension here
+}());
+```
+
+Second, client-side extensions should add a property onto `Showdown.extensions` which matches the name of the file.  As an example, a file named `demo.js` should then add `Showdown.extensions.demo`.  Server-side extensions can simply export themselves.
+
+``` js
+(function(){
+  var demo = function(converter) {
+    // ... extension code here ...
+  };
+
+  // Client-side export
+  if (typeof window !== 'undefined' && window.Showdown && window.Showdown.extensions) { window.Showdown.extensions.demo = demo; }
+  // Server-side export
+  if (typeof module !== 'undefined') module.exports = demo;
+}());
+```
+
+#### Testing Extensions
+
+The showdown test runner is setup to automatically test cases for extensions.  To add test cases for an extension, create a new folder under `./test/extensions` which matches the name of the `.js` file in `./src/extensions`.  Place any test cases into the filder using the md/html format and they will automatically be run when tests are run.
+
+
 Credits
 Credits
 ---------------------------
 ---------------------------
 
 

+ 6 - 2
package.json

@@ -15,12 +15,16 @@
     ],
     ],
     "repository": {
     "repository": {
         "type": "git",
         "type": "git",
-        "url": "https://github.com/coreyti/showdown.git"
+        "url":  "https://github.com/coreyti/showdown.git",
+        "web":  "https://github.com/coreyti/showdown"
     },
     },
     "devDependencies": {
     "devDependencies": {
         "mocha": "*",
         "mocha": "*",
         "should": "*"
         "should": "*"
     },
     },
-    "licenses": [{ "type": "BSD" }],
+    "licenses": [{
+        "type": "BSD",
+        "url":  "https://github.com/coreyti/showdown/raw/master/license.txt"
+    }],
     "main": "./src/showdown"
     "main": "./src/showdown"
 }
 }

+ 30 - 0
src/extensions/google-prettify.js

@@ -0,0 +1,30 @@
+
+//
+//  Google Prettify
+//  A showdown extension to add Google Prettify (http://code.google.com/p/google-code-prettify/)
+//  hints to showdown's HTML output.
+//
+
+(function(){
+
+    var prettify = function(converter) {
+        return [
+            { type: 'output', filter: function(source){
+
+                return source.replace(/(<pre>)?<code>/gi, function(match, pre) {
+                    if (pre) {
+                        return '<pre class="prettyprint linenums" tabIndex="0"><code data-inner="1">';
+                    } else {
+                        return '<code class="prettyprint">';
+                    }
+                });
+            }}
+        ];
+    };
+
+    // Client-side export
+    if (typeof window !== 'undefined' && window.Showdown && window.Showdown.extensions) { window.Showdown.extensions.googlePrettify = prettify; }
+    // Server-side export
+    if (typeof module !== 'undefined') module.exports = prettify;
+
+}());

+ 43 - 0
src/extensions/twitter.js

@@ -0,0 +1,43 @@
+
+//
+//  Twitter Extension
+//  @username   ->  <a href="http://twitter.com/username">@username</a>
+//  #hashtag    ->  <a href="http://twitter.com/search/%23hashtag">#hashtag</a>
+//
+
+(function(){
+
+    var twitter = function(converter) {
+        return [
+
+            // @username syntax
+            { type: 'lang', regex: '\\B(\\\\)?@([\\S]+)\\b', replace: function(match, leadingSlash, username) {
+                // Check if we matched the leading \ and return nothing changed if so
+                if (leadingSlash === '\\') {
+                    return match;
+                } else {
+                    return '<a href="http://twitter.com/' + username + '">@' + username + '</a>';
+                }
+            }},
+
+            // #hashtag syntax
+            { type: 'lang', regex: '\\B(\\\\)?#([\\S]+)\\b', replace: function(match, leadingSlash, tag) {
+                // Check if we matched the leading \ and return nothing changed if so
+                if (leadingSlash === '\\') {
+                    return match;
+                } else {
+                    return '<a href="http://twitter.com/search/%23' + tag + '">#' + tag + '</a>';
+                }
+            }},
+
+            // Escaped @'s
+            { type: 'lang', regex: '\\\\@', replace: '@' }
+        ];
+    };
+
+    // Client-side export
+    if (typeof window !== 'undefined' && window.Showdown && window.Showdown.extensions) { window.Showdown.extensions.twitter = twitter; }
+    // Server-side export
+    if (typeof module !== 'undefined') module.exports = twitter;
+
+}());

+ 120 - 15
src/showdown.js

@@ -64,7 +64,28 @@
 //
 //
 // Showdown namespace
 // Showdown namespace
 //
 //
-var Showdown = {};
+var Showdown = { extensions: {} };
+
+//
+// forEach
+//
+var forEach = Showdown.forEach = function(obj, callback) {
+	if (typeof obj.forEach === 'function') {
+		obj.forEach(callback);
+	} else {
+		var i, len = obj.length;
+		for (i = 0; i < len; i++) {
+			callback(obj[i], i, obj);
+		}
+	}
+};
+
+//
+// Standard extension naming
+//
+var stdExtName = function(s) {
+	return s.replace(/[_-]||\s/g, '').toLowerCase();
+};
 
 
 //
 //
 // converter
 // converter
@@ -72,7 +93,7 @@ var Showdown = {};
 // Wraps all "globals" so that the only thing
 // Wraps all "globals" so that the only thing
 // exposed is makeHtml().
 // exposed is makeHtml().
 //
 //
-Showdown.converter = function() {
+Showdown.converter = function(converter_options) {
 
 
 //
 //
 // Globals:
 // Globals:
@@ -87,6 +108,68 @@ var g_html_blocks;
 // (see _ProcessListItems() for details):
 // (see _ProcessListItems() for details):
 var g_list_level = 0;
 var g_list_level = 0;
 
 
+// Global extensions
+var g_lang_extensions = [];
+var g_output_modifiers = [];
+
+
+//
+// Automatic Extension Loading (node only):
+//
+
+if (typeof module !== 'undefind' && typeof exports !== 'undefined' && typeof require !== 'undefind') {
+	var fs = require('fs');
+
+	if (fs) {
+		// Search extensions folder
+		var extensions = fs.readdirSync((__dirname || '.')+'/extensions').filter(function(file){
+			return ~file.indexOf('.js');
+		}).map(function(file){
+			return file.replace(/\.js$/, '');
+		});
+		// Load extensions into Showdown namespace
+		extensions.forEach(function(ext){
+			var name = stdExtName(ext);
+			Showdown.extensions[name] = require('./extensions/' + ext);
+		});
+	}
+}
+
+//
+// Options:
+//
+
+// Parse extensinos options into separate arrays
+if (converter_options && converter_options.extensions) {
+
+	// Iterate over each plugin
+	converter_options.extensions.forEach(function(plugin){
+
+		// Assume it's a bundled plugin if a string is given
+		if (typeof plugin === 'string') {
+			plugin = Showdown.extensions[stdExtName(plugin)];
+		}
+
+		if (typeof plugin === 'function') {
+			// Iterate over each extension within that plugin
+			plugin(this).forEach(function(ext){
+				// Sort extensions by type
+				if (ext.type) {
+					if (ext.type === 'language' || ext.type === 'lang') {
+						g_lang_extensions.push(ext);
+					} else if (ext.type === 'output' || ext.type === 'html') {
+						g_output_modifiers.push(ext);
+					}
+				} else {
+					// Assume language extension
+					g_output_modifiers.push(ext);
+				}
+			});
+		} else {
+			throw "Extension '" + plugin + "' could not be loaded.  It was either not found or is not a valid extension.";
+		}
+	});
+}
 
 
 this.makeHtml = function(text) {
 this.makeHtml = function(text) {
 //
 //
@@ -100,9 +183,9 @@ this.makeHtml = function(text) {
 	// from other articles when generating a page which contains more than
 	// from other articles when generating a page which contains more than
 	// one article (e.g. an index page that shows the N most recent
 	// one article (e.g. an index page that shows the N most recent
 	// articles):
 	// articles):
-	g_urls = new Array();
-	g_titles = new Array();
-	g_html_blocks = new Array();
+	g_urls = {};
+	g_titles = {};
+	g_html_blocks = [];
 
 
 	// attacklab: Replace ~ with ~T
 	// attacklab: Replace ~ with ~T
 	// This lets us use tilde as an escape char to avoid md5 hashes
 	// This lets us use tilde as an escape char to avoid md5 hashes
@@ -131,6 +214,11 @@ this.makeHtml = function(text) {
 	// contorted like /[ \t]*\n+/ .
 	// contorted like /[ \t]*\n+/ .
 	text = text.replace(/^[ \t]+$/mg,"");
 	text = text.replace(/^[ \t]+$/mg,"");
 
 
+	// Run language extensions
+	g_lang_extensions.forEach(function(x){
+		text = _ExecuteExtension(x, text);
+	});
+
 	// Handle github codeblocks prior to running HashHTML so that
 	// Handle github codeblocks prior to running HashHTML so that
 	// HTML contained within the codeblock gets escaped propertly
 	// HTML contained within the codeblock gets escaped propertly
 	text = _DoGithubCodeBlocks(text);
 	text = _DoGithubCodeBlocks(text);
@@ -151,10 +239,24 @@ this.makeHtml = function(text) {
 	// attacklab: Restore tildes
 	// attacklab: Restore tildes
 	text = text.replace(/~T/g,"~");
 	text = text.replace(/~T/g,"~");
 
 
+	// Run output modifiers
+	g_output_modifiers.forEach(function(x){
+		text = _ExecuteExtension(x, text);
+	});
+
 	return text;
 	return text;
 };
 };
 
 
 
 
+var _ExecuteExtension = function(ext, text) {
+	if (ext.regex) {
+		var re = new RegExp(ext.regex, 'g');
+		return text.replace(re, ext.replace);
+	} else if (ext.filter) {
+		return ext.filter(text);
+	}
+};
+
 var _StripLinkDefinitions = function(text) {
 var _StripLinkDefinitions = function(text) {
 //
 //
 // Strips link definitions from text, stores the URLs and titles in
 // Strips link definitions from text, stores the URLs and titles in
@@ -483,7 +585,7 @@ var _DoAnchors = function(text) {
 		)
 		)
 		/g,writeAnchorTag);
 		/g,writeAnchorTag);
 	*/
 	*/
-	text = text.replace(/(\[((?:\[[^\]]*\]|[^\[\]])*)\]\([ \t]*()<?(.*?)>?[ \t]*((['"])(.*?)\6[ \t]*)?\))/g,writeAnchorTag);
+	text = text.replace(/(\[((?:\[[^\]]*\]|[^\[\]])*)\]\([ \t]*()<?(.*?(?:\(.*?\).*?)?)>?[ \t]*((['"])(.*?)\6[ \t]*)?\))/g,writeAnchorTag);
 
 
 	//
 	//
 	// Last, handle reference-style shortcuts: [link text]
 	// Last, handle reference-style shortcuts: [link text]
@@ -1089,7 +1191,7 @@ var _FormParagraphs = function(text) {
 	text = text.replace(/\n+$/g,"");
 	text = text.replace(/\n+$/g,"");
 
 
 	var grafs = text.split(/\n{2,}/g);
 	var grafs = text.split(/\n{2,}/g);
-	var grafsOut = new Array();
+	var grafsOut = [];
 
 
 	//
 	//
 	// Wrap <p> tags.
 	// Wrap <p> tags.
@@ -1208,16 +1310,9 @@ var _EncodeEmailAddress = function(addr) {
 //  mailing list: <http://tinyurl.com/yu7ue>
 //  mailing list: <http://tinyurl.com/yu7ue>
 //
 //
 
 
-	// attacklab: why can't javascript speak hex?
-	function char2hex(ch) {
-		var hexDigits = '0123456789ABCDEF';
-		var dec = ch.charCodeAt(0);
-		return(hexDigits.charAt(dec>>4) + hexDigits.charAt(dec&15));
-	}
-
 	var encode = [
 	var encode = [
 		function(ch){return "&#"+ch.charCodeAt(0)+";";},
 		function(ch){return "&#"+ch.charCodeAt(0)+";";},
-		function(ch){return "&#x"+char2hex(ch)+";";},
+		function(ch){return "&#x"+ch.charCodeAt(0).toString(16)+";";},
 		function(ch){return ch;}
 		function(ch){return ch;}
 	];
 	];
 
 
@@ -1337,5 +1432,15 @@ var escapeCharacters_callback = function(wholeMatch,m1) {
 
 
 } // end of Showdown.converter
 } // end of Showdown.converter
 
 
+
 // export
 // export
 if (typeof module !== 'undefined') module.exports = Showdown;
 if (typeof module !== 'undefined') module.exports = Showdown;
+
+// stolen from AMD branch of underscore
+// AMD define happens at the end for compatibility with AMD loaders
+// that don't enforce next-turn semantics on modules.
+if (typeof define === 'function' && define.amd) {
+    define('showdown', function() {
+        return Showdown;
+    });
+}

+ 2 - 0
test/cases/url-with-parenthesis.html

@@ -0,0 +1,2 @@
+
+<p>There's an <a href="http://en.memory-alpha.org/wiki/Darmok_(episode)">episode</a> of Star Trek: The Next Generation</p>

+ 2 - 0
test/cases/url-with-parenthesis.md

@@ -0,0 +1,2 @@
+
+There's an [episode](http://en.memory-alpha.org/wiki/Darmok_(episode)) of Star Trek: The Next Generation

+ 7 - 0
test/extensions/google-prettify/basic.html

@@ -0,0 +1,7 @@
+
+<p>Here's a simple hello world in javascript:</p>
+
+<pre class="prettyprint linenums" tabIndex="0"><code data-inner="1">alert('Hello World!');
+</code></pre>
+
+<p>The <code class="prettyprint">alert</code> function is a build-in global from <code class="prettyprint">window</code>.</p>

+ 6 - 0
test/extensions/google-prettify/basic.md

@@ -0,0 +1,6 @@
+
+Here's a simple hello world in javascript:
+
+    alert('Hello World!');
+
+The `alert` function is a build-in global from `window`.

+ 5 - 0
test/extensions/twitter/basic.html

@@ -0,0 +1,5 @@
+<p>Testing of the twitter extension.</p>
+
+<p>Ping <a href="http://twitter.com/andstuff">@andstuff</a> to find out more about <a href="http://twitter.com/search/%23extensions">#extensions</a> with showdown</p>
+
+<p>And @something shouldn't render as a twitter link</p>

+ 5 - 0
test/extensions/twitter/basic.md

@@ -0,0 +1,5 @@
+Testing of the twitter extension.
+
+Ping @andstuff to find out more about #extensions with showdown
+
+And \@something shouldn't render as a twitter link

+ 78 - 36
test/run.js

@@ -1,44 +1,86 @@
 var showdown    = new require('../src/showdown'),
 var showdown    = new require('../src/showdown'),
-    convertor   = new showdown.converter(),
     fs          = require('fs'),
     fs          = require('fs'),
     path        = require('path'),
     path        = require('path'),
     should      = require('should');
     should      = require('should');
 
 
-// Load test cases from disk
-var cases = fs.readdirSync('test/cases').filter(function(file){
-    return ~file.indexOf('.md');
-}).map(function(file){
-    return file.replace('.md', '');
+
+var runTestsInDir = function(dir, converter) {
+
+    // Load test cases from disk
+    var cases = fs.readdirSync(dir).filter(function(file){
+        return ~file.indexOf('.md');
+    }).map(function(file){
+        return file.replace('.md', '');
+    });
+
+    // Run each test case (markdown -> html)
+    cases.forEach(function(test){
+        var name = test.replace(/[-.]/g, ' ');
+        it (name, function(){
+            var mdpath = path.join(dir, test + '.md'),
+                htmlpath = path.join(dir, test + '.html'),
+                md = fs.readFileSync(mdpath, 'utf8'),
+                expected = fs.readFileSync(htmlpath, 'utf8').trim(),
+                actual = converter.makeHtml(md).trim();
+
+            // Normalize line returns
+            expected = expected.replace(/\r/g, '');
+
+            // Ignore all leading/trailing whitespace
+            expected = expected.split('\n').map(function(x){
+                return x.trim();
+            }).join('\n');
+            actual = actual.split('\n').map(function(x){
+                return x.trim();
+            }).join('\n');
+
+            // Convert whitespace to a visible character so that it shows up on error reports
+            expected = expected.replace(/ /g, '·');
+            expected = expected.replace(/\n/g, '•\n');
+            actual = actual.replace(/ /g, '·');
+            actual = actual.replace(/\n/g, '•\n');
+
+            // Compare
+            actual.should.equal(expected);
+        });
+    });
+};
+
+
+//
+// :: Markdown to HTML testing ::
+//
+
+describe('Markdown', function() {
+    var converter = new showdown.converter();
+    runTestsInDir('test/cases', converter);
 });
 });
 
 
-// Run each test case
-cases.forEach(function(test){
-    var name = test.replace(/[-.]/g, ' ');
-    it (name, function(){
-        var mdpath = path.join('test/cases', test + '.md'),
-            htmlpath = path.join('test/cases', test + '.html'),
-            md = fs.readFileSync(mdpath, 'utf8'),
-            expected = fs.readFileSync(htmlpath, 'utf8').trim(),
-            actual = convertor.makeHtml(md).trim();
-
-        // Normalize line returns
-        expected = expected.replace(/\r/g, '');
-
-        // Ignore all leading/trailing whitespace
-        expected = expected.split('\n').map(function(x){
-            return x.trim();
-        }).join('\n');
-        actual = actual.split('\n').map(function(x){
-            return x.trim();
-        }).join('\n');
-
-        // Convert whitespace to a visible character so that it shows up on error reports
-        expected = expected.replace(/ /g, '·');
-        expected = expected.replace(/\n/g, '•\n');
-        actual = actual.replace(/ /g, '·');
-        actual = actual.replace(/\n/g, '•\n');
-
-        // Compare
-        actual.should.equal(expected);
+
+//
+// :: Extensions Testing ::
+//
+
+if (path.existsSync('test/extensions')) {
+
+    describe('extensions', function() {
+        // Search all sub-folders looking for directory-specific tests
+        var extensions = fs.readdirSync('test/extensions').filter(function(file){
+            return fs.lstatSync('test/extensions/' + file).isDirectory();
+        });
+
+        // Run tests in each extension sub-folder
+        extensions.forEach(function(ext){
+            // Make sure extension exists
+            var src = 'src/extensions/' + ext + '.js';
+            if (!path.existsSync(src)) {
+                throw "Attempting tests for '" + ext + "' but sourc file (" + src + ") was not found.";
+            }
+
+            var converter = new showdown.converter({ extensions: [ ext ] });
+            var dir = 'test/extensions/' + ext;
+            runTestsInDir(dir, converter);
+        });
     });
     });
-});
+
+}