Эх сурвалжийг харах

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

Corey Innis 12 жил өмнө
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.
 
 
+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
 ---------------------------
 
@@ -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`.
 
 
+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
 ---------------------------
 

+ 6 - 2
package.json

@@ -15,12 +15,16 @@
     ],
     "repository": {
         "type": "git",
-        "url": "https://github.com/coreyti/showdown.git"
+        "url":  "https://github.com/coreyti/showdown.git",
+        "web":  "https://github.com/coreyti/showdown"
     },
     "devDependencies": {
         "mocha": "*",
         "should": "*"
     },
-    "licenses": [{ "type": "BSD" }],
+    "licenses": [{
+        "type": "BSD",
+        "url":  "https://github.com/coreyti/showdown/raw/master/license.txt"
+    }],
     "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
 //
-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
@@ -72,7 +93,7 @@ var Showdown = {};
 // Wraps all "globals" so that the only thing
 // exposed is makeHtml().
 //
-Showdown.converter = function() {
+Showdown.converter = function(converter_options) {
 
 //
 // Globals:
@@ -87,6 +108,68 @@ var g_html_blocks;
 // (see _ProcessListItems() for details):
 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) {
 //
@@ -100,9 +183,9 @@ this.makeHtml = function(text) {
 	// from other articles when generating a page which contains more than
 	// one article (e.g. an index page that shows the N most recent
 	// 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
 	// 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+/ .
 	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
 	// HTML contained within the codeblock gets escaped propertly
 	text = _DoGithubCodeBlocks(text);
@@ -151,10 +239,24 @@ this.makeHtml = function(text) {
 	// attacklab: Restore tildes
 	text = text.replace(/~T/g,"~");
 
+	// Run output modifiers
+	g_output_modifiers.forEach(function(x){
+		text = _ExecuteExtension(x, 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) {
 //
 // Strips link definitions from text, stores the URLs and titles in
@@ -483,7 +585,7 @@ var _DoAnchors = function(text) {
 		)
 		/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]
@@ -1089,7 +1191,7 @@ var _FormParagraphs = function(text) {
 	text = text.replace(/\n+$/g,"");
 
 	var grafs = text.split(/\n{2,}/g);
-	var grafsOut = new Array();
+	var grafsOut = [];
 
 	//
 	// Wrap <p> tags.
@@ -1208,16 +1310,9 @@ var _EncodeEmailAddress = function(addr) {
 //  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 = [
 		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;}
 	];
 
@@ -1337,5 +1432,15 @@ var escapeCharacters_callback = function(wholeMatch,m1) {
 
 } // end of Showdown.converter
 
+
 // export
 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'),
-    convertor   = new showdown.converter(),
     fs          = require('fs'),
     path        = require('path'),
     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);
+        });
     });
-});
+
+}