Merge pull request #10 from caffinatedmonkey/images

Images Support
diff --git a/lib/src/inline_parser.dart b/lib/src/inline_parser.dart
index 392ca69..6772c84 100644
--- a/lib/src/inline_parser.dart
+++ b/lib/src/inline_parser.dart
@@ -29,6 +29,7 @@
 
     new AutolinkSyntax(),
     new LinkSyntax(),
+    new ImageLinkSyntax(),
     // "*" surrounded by spaces is left alone.
     new TextSyntax(r' \* '),
     // "_" surrounded by spaces is left alone.
@@ -83,8 +84,11 @@
     } else {
       syntaxes = defaultSyntaxes;
     }
-    // Custom link resolver goes after the generic text syntax.
-    syntaxes.insert(1, new LinkSyntax(linkResolver: document.linkResolver));
+    // Custom link resolvers goes after the generic text syntax.
+    syntaxes.insertAll(1, [
+      new LinkSyntax(linkResolver: document.linkResolver),
+      new ImageLinkSyntax(linkResolver: document.linkResolver)
+    ]);
   }
 
   List<Node> parse() {
@@ -214,8 +218,8 @@
   bool onMatch(InlineParser parser, Match match) {
     final url = match[1];
 
-    final anchor = new Element.text('a', escapeHtml(url));
-    anchor.attributes['href'] = url;
+    final anchor = new Element.text('a', escapeHtml(url))
+      ..attributes['href'] = url;
     parser.addNode(anchor);
 
     return true;
@@ -267,76 +271,105 @@
     // 4: Contains the title, if present, for an inline link.
   }
 
-  LinkSyntax({this.linkResolver})
-    : super(r'\[', end: linkPattern);
+  LinkSyntax({this.linkResolver, String pattern: r'\['})
+    : super(pattern, end: linkPattern);
 
-  bool onMatchEnd(InlineParser parser, Match match, TagState state) {
-    var url;
-    var title;
-
+  Node createNode(InlineParser parser, Match match, TagState state) {
     // If we didn't match refLink or inlineLink, then it means there was
     // nothing after the first square bracket, so it isn't a normal markdown
     // link at all. Instead, we allow users of the library to specify a special
     // resolver function ([linkResolver]) that may choose to handle
     // this. Otherwise, it's just treated as plain text.
-    if ((match[1] == null) || (match[1] == '')) {
-      if (linkResolver == null) return false;
+    if (isNullOrEmpty(match[1])) {
+      if (linkResolver == null) return null;
 
       // Only allow implicit links if the content is just text.
       // TODO(rnystrom): Do we want to relax this?
-      if (state.children.any((child) => child is! Text)) return false;
+      if (state.children.any((child) => child is! Text)) return null;
       // If there are multiple children, but they are all text, send the
       // combined text to linkResolver.
       var textToResolve = state.children.fold('',
           (oldVal, child) => oldVal + child.text);
+
       // See if we have a resolver that will generate a link for us.
-      final node = linkResolver(textToResolve);
-      if (node == null) return false;
+      return linkResolver(textToResolve);
+    } else {
+      Link link = getLink(parser, match, state);
+      if (link == null) return null;
 
-      parser.addNode(node);
-      return true;
+      final Element node = new Element('a', state.children)
+        ..attributes["href"] = escapeHtml(link.url)
+        ..attributes['title'] = escapeHtml(link.title);
+
+      cleanMap(node.attributes);
+      return node;
     }
+  }
 
+  Link getLink(InlineParser parser, Match match, TagState state) {
     if ((match[3] != null) && (match[3] != '')) {
       // Inline link like [foo](url).
-      url = match[3];
-      title = match[4];
+      var url = match[3];
+      var title = match[4];
 
       // For whatever reason, markdown allows angle-bracketed URLs here.
       if (url.startsWith('<') && url.endsWith('>')) {
         url = url.substring(1, url.length - 1);
       }
+
+      return new Link(null, url, title);
     } else {
+      var id;
       // Reference link like [foo] [bar].
-      var id = match[2];
-      if (id == '') {
+      if (match[2] == '')
         // The id is empty ("[]") so infer it from the contents.
         id = parser.source.substring(state.startPos + 1, parser.pos);
-      }
+      else
+        id = match[2];
 
       // References are case-insensitive.
       id = id.toLowerCase();
-
-      // Look up the link.
-      final link = parser.document.refLinks[id];
-      // If it's an unknown link just emit plaintext.
-      if (link == null) return false;
-
-      url = link.url;
-      title = link.title;
+      return parser.document.refLinks[id];
     }
+  }
 
-    final anchor = new Element('a', state.children);
-    anchor.attributes['href'] = escapeHtml(url);
-    if ((title != null) && (title != '')) {
-      anchor.attributes['title'] = escapeHtml(title);
-    }
-
-    parser.addNode(anchor);
+  bool onMatchEnd(InlineParser parser, Match match, TagState state) {
+    Node node = createNode(parser, match, state);
+    if (node == null) return false;
+    parser.addNode(node);
     return true;
   }
 }
 
+/// Matches images like `![alternate text](url "optional title")` and
+/// `![alternate text][url reference]`.
+class ImageLinkSyntax extends LinkSyntax {
+  Resolver linkResolver;
+  ImageLinkSyntax({this.linkResolver})
+    : super(pattern: r'!\[');
+
+  Node createNode(InlineParser parser, Match match, TagState state) {
+    Node node = super.createNode(parser, match, state);
+    if (node == null) return null;
+
+    final Element imageElement = new Element.withTag("img")
+      ..attributes["src"] = node.attributes["href"]
+      ..attributes["title"] = node.attributes["title"]
+      ..attributes["alt"] = node.children
+        .map((e) => isNullOrEmpty(e) || e is! Text ? '' : e.text)
+        .join(' ');
+
+    cleanMap(imageElement.attributes);
+
+    node.children
+      ..clear()
+      ..add(imageElement);
+
+    return node;
+  }
+}
+
+
 /// Matches backtick-enclosed inline code blocks.
 class CodeSyntax extends InlineSyntax {
   CodeSyntax(String pattern)
diff --git a/lib/src/util.dart b/lib/src/util.dart
index a6d52de..2da80da 100644
--- a/lib/src/util.dart
+++ b/lib/src/util.dart
@@ -2,7 +2,21 @@
 
 /// Replaces `<`, `&`, and `>`, with their HTML entity equivalents.
 String escapeHtml(String html) {
+  if (html == '' || html == null) return null;
   return html.replaceAll('&', '&amp;')
              .replaceAll('<', '&lt;')
              .replaceAll('>', '&gt;');
 }
+
+/// Removes null or empty values from [map].
+void cleanMap(Map map) {
+  map.keys
+    .where((e) => isNullOrEmpty(map[e]))
+    .toList()
+    .forEach(map.remove);
+}
+
+/// Returns true if an object is null or an empty string.
+bool isNullOrEmpty(object) {
+  return object == null || object == '';
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index e5e9f0f..e62748f 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: markdown
-version: 0.5.1
+version: 0.6.0-dev
 author: Dart Team <misc@dartlang.org>
 description: A library for converting markdown to HTML.
 homepage: https://github.com/dpeek/dart-markdown
diff --git a/test/markdown_test.dart b/test/markdown_test.dart
index 3c4564b..e1cc81f 100644
--- a/test/markdown_test.dart
+++ b/test/markdown_test.dart
@@ -862,6 +862,94 @@
         ''');
   });
 
+  group('Inline Images', () {
+    validate('image','''
+        ![](http://foo.com/foo.png)
+        ''','''
+        <p>
+          <a href="http://foo.com/foo.png">
+            <img src="http://foo.com/foo.png"></img>
+          </a>
+        </p>
+        ''');
+
+    validate('alternate text','''
+        ![alternate text](http://foo.com/foo.png)
+        ''','''
+        <p>
+          <a href="http://foo.com/foo.png">
+            <img alt="alternate text" src="http://foo.com/foo.png"></img>
+          </a>
+        </p>
+        ''');
+
+    validate('title','''
+        ![](http://foo.com/foo.png "optional title")
+        ''','''
+        <p>
+          <a href="http://foo.com/foo.png" title="optional title">
+            <img src="http://foo.com/foo.png" title="optional title"></img>
+          </a>
+        </p>
+        ''');
+    validate('invalid alt text','''
+        ![`alt`](http://foo.com/foo.png)
+        ''','''
+        <p>
+          <a href="http://foo.com/foo.png">
+            <img src="http://foo.com/foo.png"></img>
+          </a>
+        </p>
+        ''');
+  });
+
+  group('Reference Images', () {
+    validate('image','''
+        ![][foo]
+        [foo]: http://foo.com/foo.png
+        ''','''
+        <p>
+          <a href="http://foo.com/foo.png">
+            <img src="http://foo.com/foo.png"></img>
+          </a>
+        </p>
+        ''');
+
+    validate('alternate text','''
+        ![alternate text][foo]
+        [foo]: http://foo.com/foo.png
+        ''','''
+        <p>
+          <a href="http://foo.com/foo.png">
+            <img alt="alternate text" src="http://foo.com/foo.png"></img>
+          </a>
+        </p>
+        ''');
+
+    validate('title','''
+        ![][foo]
+        [foo]: http://foo.com/foo.png "optional title"
+        ''','''
+        <p>
+          <a href="http://foo.com/foo.png" title="optional title">
+            <img src="http://foo.com/foo.png" title="optional title"></img>
+          </a>
+        </p>
+        ''');
+
+    validate('invalid alt text','''
+        ![`alt`][foo]
+        [foo]: http://foo.com/foo.png "optional title"
+        ''','''
+        <p>
+          <a href="http://foo.com/foo.png" title="optional title">
+            <img src="http://foo.com/foo.png" title="optional title"></img>
+          </a>
+        </p>
+        ''');
+
+  });
+
   group('Resolver', () {
     var nyanResolver = (text) => new Text('~=[,,_${text}_,,]:3');
     validate('simple resolver', '''