Merge "Switch to commonmark 0.5.0"
diff --git a/Documentation/markdown.md b/Documentation/markdown.md
index e1c6da3..652d5ed 100644
--- a/Documentation/markdown.md
+++ b/Documentation/markdown.md
@@ -140,7 +140,6 @@
 | Apple |    95    | Yes    |
 | Pear  |   102    | Yes    |
 | Hay   |   977    |        |
-[Food and its benefits]
 ```
 
 will render as:
@@ -150,35 +149,12 @@
 | Apple |    95    | Yes    |
 | Pear  |   102    | Yes    |
 | Hay   |   977    |        |
-[Food and its benefits]
 
 Placing `:` in the separator line indicates how the column should be
 aligned.  A colon on the left side is a **left-aligned** column; a
 colon on the right-most side is **right-aligned**; a colon on both
 sides is **center-aligned**.
 
-An optional table title can be placed under the table in brackets
-(`[...]`).
-
-Cells may span multiple columns and include formatting accepted within
-paragraphs such as emphasis, images or links:
-
-|              | Grouping                    ||
-| First Header | Second Header | Third Header |
-| ------------ | :-----------: | -----------: |
-| Content      | *Long Cell*                 ||
-| Content      | **Cell 2**    | Cell 3       |
-
-the above table was created by:
-
-```
-|              | Grouping                    ||
-| First Header | Second Header | Third Header |
-| ------------ | :-----------: | -----------: |
-| Content      | *Long Cell*                 ||
-| Content      | **Cell 2**    | Cell 3       |
-```
-
 Empty table cells are indicated by whitespace between the column
 dividers (`| |`) while multiple column cells omit the whitespace.
 
@@ -213,14 +189,11 @@
 
 ### Smart quotes
 
-'Single', "double" and <<double angle>> quotes in paragraph text are
+'Single' and "double" quotes in paragraph text are
 replaced with smart quotes.  Apostrophes (this doc's text), ellipses
 ("...") and dashes ("--" and "---") are also replaced with HTML
 entities to make the documentation appear typeset.
 
-To force use of the ASCII characters prefix with \, for example `\'`
-for a \' normal single quote.
-
 ### Blockquotes
 
 Blockquoted text can be used to stand off text obtained from
@@ -253,20 +226,20 @@
 Create a fenced code block using three backticks before and after a
 block of code, preceeded and followed by blank lines:
 
+````
+This is a simple hello world program in C:
+
+``` c
+#include <stdio.h>
+
+int main() {
+  printf("Hello, World.\n");
+  return 0;
+}
 ```
- This is a simple hello world program in C:
 
- ```c
- #include <stdio.h>
-
- int main() {
-   printf("Hello, World.\n");
-   return 0;
- }
- ```
-
- To compile it use `gcc hello.c`.
-```
+To compile it use `gcc hello.c`.
+````
 
 Text within a fenced code block is taken verbatim and is not
 processed for Markdown markup.
@@ -331,7 +304,7 @@
 line will also create a horizontal rule:
 
 ```
---
+---
 
 - - - -
 
@@ -469,12 +442,13 @@
 
 ### HTML
 
-HTML tags are not supported.  HTML will be dropped on the floor by the
-parser with no warnings, and no output from that section of the
+Most HTML tags are not supported.  HTML will be dropped on the floor
+by the parser with no warnings, and no output from that section of the
 document.
 
-There is a small exception for `<a name>` and `<iframe>` elements, see
-[named anchor](#Named-anchors) and [HTML IFrame](#HTML-IFrame).
+There are small exceptions for `<br>`, `<hr>`, `<a name>` and
+`<iframe>` elements, see [named anchor](#Named-anchors) and
+[HTML IFrame](#HTML-IFrame).
 
 ## Markdown extensions
 
@@ -768,17 +742,6 @@
   imageLimit = 256K
 ```
 
-### Parsing timeout
-
-Parsing Markdown can be expensive so this implementation places
-a default upper bound of 2 seconds on running time per document.
-This is measured in wall clock time from the start of the request.
-
-```
-[markdown]
-  parseTimeout = 2s
-```
-
 ### Google Analytics
 
 [Google Analytics](https://www.google.com/analytics/) can be
diff --git a/README.md b/README.md
index 21bb473..686f964 100644
--- a/README.md
+++ b/README.md
@@ -63,20 +63,22 @@
 Code Style
 ----------
 
-Java code in Gitiles follows the [Google Java Style Guide]
-(https://google.github.io/styleguide/javaguide.html) with a 100-column limit.
+Java code in Gitiles follows the [Google Java Style Guide][java-style]
+with a 100-column limit.
 
-Code should be automatically formatted using [google-java-format]
-(https://github.com/google/google-java-format) prior to sending a code review.
-There is currently no Eclipse formatter, but the tool can be run from the
-command line:
+Code should be automatically formatted using [google-java-format][fmt]
+prior to sending a code review.  There is currently no Eclipse
+formatter, but the tool can be run from the command line:
 
 ```
 java -jar /path/to/google-java-format-1.0-all-deps.jar -i path/to/java/File.java
 ```
 
-CSS in Gitiles follows the [SUIT CSS naming conventions]
-(https://github.com/suitcss/suit/blob/master/doc/naming-conventions.md).
+CSS in Gitiles follows the [SUIT CSS naming conventions][suit].
+
+[java-style]: https://google.github.io/styleguide/javaguide.html
+[fmt]: https://github.com/google/google-java-format
+[suit]: https://github.com/suitcss/suit/blob/master/doc/naming-conventions.md
 
 Code Review
 -----------
diff --git a/gitiles-servlet/BUCK b/gitiles-servlet/BUCK
index 9ce4283..97dde01 100644
--- a/gitiles-servlet/BUCK
+++ b/gitiles-servlet/BUCK
@@ -3,12 +3,14 @@
 DEPS = [
   '//blame-cache:lib',
   '//lib:commons-lang',
-  '//lib:grappa',
   '//lib:gson',
   '//lib:guava',
   '//lib:joda-time',
   '//lib:jsr305',
-  '//lib:pegdown',
+  '//lib:commonmark',
+  '//lib:cm-autolink',
+  '//lib:gfm-tables',
+  '//lib:gfm-strikethrough',
   '//lib:prettify',
   '//lib/jgit:jgit',
   '//lib/jgit:jgit-servlet',
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/ReadmeHelper.java b/gitiles-servlet/src/main/java/com/google/gitiles/ReadmeHelper.java
index 10b2048..eee4d83 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/ReadmeHelper.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/ReadmeHelper.java
@@ -19,6 +19,7 @@
 import com.google.gitiles.doc.MarkdownToHtml;
 import com.google.template.soy.data.SanitizedContent;
 
+import org.commonmark.node.Node;
 import org.eclipse.jgit.errors.CorruptObjectException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.LargeObjectException;
@@ -31,8 +32,6 @@
 import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.treewalk.TreeWalk;
 import org.eclipse.jgit.util.RawParseUtils;
-import org.joda.time.Duration;
-import org.pegdown.ast.RootNode;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -90,13 +89,10 @@
 
   SanitizedContent render() {
     try {
-      Duration parseTimeout =
-          ConfigUtil.getDuration(
-              cfg, "markdown", null, "parseTimeout", Duration.standardSeconds(2));
       int inputLimit = cfg.getInt("markdown", "inputLimit", 5 << 20);
       byte[] raw = reader.open(readmeId, Constants.OBJ_BLOB).getCachedBytes(inputLimit);
       String md = RawParseUtils.decode(raw);
-      RootNode root = GitilesMarkdown.parseFile(parseTimeout, view, readmePath, md);
+      Node root = GitilesMarkdown.parse(md);
       if (root == null) {
         return null;
       }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/Visitor.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/BlockNote.java
similarity index 63%
copy from gitiles-servlet/src/main/java/com/google/gitiles/doc/Visitor.java
copy to gitiles-servlet/src/main/java/com/google/gitiles/doc/BlockNote.java
index 6c9f2f6..ef1f4c9 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/Visitor.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/BlockNote.java
@@ -4,7 +4,7 @@
 // you may not use this file except in compliance with the License.
 // You may obtain a copy of the License at
 //
-//     http://www.apache.org/licenses/LICENSE-2.0
+// http://www.apache.org/licenses/LICENSE-2.0
 //
 // Unless required by applicable law or agreed to in writing, software
 // distributed under the License is distributed on an "AS IS" BASIS,
@@ -14,16 +14,17 @@
 
 package com.google.gitiles.doc;
 
-public interface Visitor extends org.pegdown.ast.Visitor {
-  void visit(ColsNode node);
+import org.commonmark.node.CustomBlock;
 
-  void visit(ColsNode.Column node);
+/** Block note to render as {@code <div class="clazz">}. */
+public class BlockNote extends CustomBlock {
+  private String clazz;
 
-  void visit(DivNode node);
+  public String getClassName() {
+    return clazz;
+  }
 
-  void visit(IframeNode node);
-
-  void visit(TocNode node);
-
-  void visit(NamedAnchorNode node);
+  public void setClassName(String clazz) {
+    this.clazz = clazz;
+  }
 }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/BlockNoteExtension.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/BlockNoteExtension.java
new file mode 100644
index 0000000..4a37eee
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/BlockNoteExtension.java
@@ -0,0 +1,116 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gitiles.doc;
+
+import com.google.common.collect.ImmutableSet;
+
+import org.commonmark.Extension;
+import org.commonmark.node.Block;
+import org.commonmark.parser.Parser;
+import org.commonmark.parser.Parser.ParserExtension;
+import org.commonmark.parser.block.AbstractBlockParser;
+import org.commonmark.parser.block.AbstractBlockParserFactory;
+import org.commonmark.parser.block.BlockContinue;
+import org.commonmark.parser.block.BlockStart;
+import org.commonmark.parser.block.MatchedBlockParser;
+import org.commonmark.parser.block.ParserState;
+
+/**
+ * CommonMark extension for block notes.
+ * <pre>
+ * *** note
+ * This is a note.
+ * ***
+ * </pre>
+ */
+public class BlockNoteExtension implements ParserExtension {
+  private static final ImmutableSet<String> VALID_STYLES =
+      ImmutableSet.of("note", "aside", "promo");
+
+  public static Extension create() {
+    return new BlockNoteExtension();
+  }
+
+  private BlockNoteExtension() {}
+
+  @Override
+  public void extend(Parser.Builder builder) {
+    builder.customBlockParserFactory(new NoteParserFactory());
+  }
+
+  private static class NoteParser extends AbstractBlockParser {
+    private final BlockNote block;
+    private boolean done;
+
+    NoteParser(String style) {
+      block = new BlockNote();
+      block.setClassName(style);
+    }
+
+    @Override
+    public Block getBlock() {
+      return block;
+    }
+
+    @Override
+    public BlockContinue tryContinue(ParserState state) {
+      if (done) {
+        return BlockContinue.none();
+      }
+      if (state.getIndent() == 0) {
+        int s = state.getNextNonSpaceIndex();
+        CharSequence line = state.getLine();
+        if ("***".contentEquals(line.subSequence(s, line.length()))) {
+          done = true;
+          return BlockContinue.atIndex(line.length());
+        }
+      }
+      return BlockContinue.atIndex(state.getIndex());
+    }
+
+    @Override
+    public boolean isContainer() {
+      return true;
+    }
+
+    @Override
+    public boolean canContain(Block block) {
+      return true;
+    }
+  }
+
+  private static class NoteParserFactory extends AbstractBlockParserFactory {
+    @Override
+    public BlockStart tryStart(ParserState state, MatchedBlockParser matched) {
+      if (state.getIndent() > 0) {
+        return BlockStart.none();
+      }
+
+      int s = state.getNextNonSpaceIndex();
+      CharSequence line = state.getLine();
+      CharSequence text = line.subSequence(s, line.length());
+      if (text.length() < 4 || !"*** ".contentEquals(text.subSequence(0, 4))) {
+        return BlockStart.none();
+      }
+
+      String style = text.subSequence(4, line.length()).toString().trim();
+      if (!VALID_STYLES.contains(style)) {
+        return BlockStart.none();
+      }
+
+      return BlockStart.of(new NoteParser(style)).atIndex(line.length());
+    }
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/ColsNode.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/ColsNode.java
deleted file mode 100644
index 502c39f..0000000
--- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/ColsNode.java
+++ /dev/null
@@ -1,96 +0,0 @@
-// Copyright 2015 Google Inc. All Rights Reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gitiles.doc;
-
-import org.pegdown.ast.HeaderNode;
-import org.pegdown.ast.Node;
-import org.pegdown.ast.SuperNode;
-
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * Multi-column layout delineated by {@code |||---|||}.
- * <p>
- * Each header within the layout creates a new column in the HTML.
- */
-public class ColsNode extends SuperNode {
-  static final int GRID_WIDTH = 12;
-
-  ColsNode(List<Column> spec, List<Node> children) {
-    super(wrap(spec, children));
-  }
-
-  @Override
-  public void accept(org.pegdown.ast.Visitor visitor) {
-    ((Visitor) visitor).visit(this);
-  }
-
-  private static List<Node> wrap(List<Column> spec, List<Node> children) {
-    List<Column> columns = copyOf(spec);
-    splitChildren(columns, children);
-
-    int remaining = GRID_WIDTH;
-    for (int i = 0; i < columns.size(); i++) {
-      Column col = columns.get(i);
-      if (col.span <= 0 || col.span > GRID_WIDTH) {
-        col.span = remaining / (columns.size() - i);
-      }
-      remaining = Math.max(0, remaining - col.span);
-    }
-    return asNodeList(columns);
-  }
-
-  private static void splitChildren(List<Column> columns, List<Node> children) {
-    int idx = 0;
-    Column col = null;
-    for (Node n : children) {
-      if (col == null || n instanceof HeaderNode || n instanceof DivNode) {
-        for (; ; ) {
-          if (idx < columns.size()) {
-            col = columns.get(idx);
-          } else {
-            col = new Column();
-            columns.add(col);
-          }
-          idx++;
-          if (!col.empty) {
-            break;
-          }
-        }
-      }
-      col.getChildren().add(n);
-    }
-  }
-
-  private static <T> ArrayList<T> copyOf(List<T> in) {
-    return in != null && !in.isEmpty() ? new ArrayList<>(in) : new ArrayList<T>();
-  }
-
-  @SuppressWarnings("unchecked")
-  private static List<Node> asNodeList(List<? extends Node> columns) {
-    return (List<Node>) columns;
-  }
-
-  static class Column extends SuperNode {
-    int span;
-    boolean empty;
-
-    @Override
-    public void accept(org.pegdown.ast.Visitor visitor) {
-      ((Visitor) visitor).visit(this);
-    }
-  }
-}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/DivNode.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/DivNode.java
deleted file mode 100644
index 9bdb926..0000000
--- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/DivNode.java
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright 2015 Google Inc. All Rights Reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gitiles.doc;
-
-import org.pegdown.ast.Node;
-import org.pegdown.ast.ParaNode;
-import org.pegdown.ast.SuperNode;
-
-import java.util.List;
-
-/** Block note to render as {@code &lt;div class="clazz"&gt;}. */
-public class DivNode extends SuperNode {
-  private final String style;
-
-  DivNode(String style, List<Node> list) {
-    super(
-        list.size() == 1 && list.get(0) instanceof ParaNode
-            ? ((ParaNode) list.get(0)).getChildren()
-            : list);
-    this.style = style;
-  }
-
-  public String getStyleName() {
-    return style;
-  }
-
-  @Override
-  public void accept(org.pegdown.ast.Visitor visitor) {
-    ((Visitor) visitor).visit(this);
-  }
-}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/DocServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/DocServlet.java
index 1ba14a8..8c30e26 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/DocServlet.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/DocServlet.java
@@ -29,13 +29,13 @@
 import com.google.common.hash.Hashing;
 import com.google.common.net.HttpHeaders;
 import com.google.gitiles.BaseServlet;
-import com.google.gitiles.ConfigUtil;
 import com.google.gitiles.FormatType;
 import com.google.gitiles.GitilesAccess;
 import com.google.gitiles.GitilesView;
 import com.google.gitiles.Renderer;
 import com.google.gitiles.ViewFilter;
 
+import org.commonmark.node.Node;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.http.server.ServletUtils;
 import org.eclipse.jgit.lib.Config;
@@ -48,8 +48,6 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.treewalk.TreeWalk;
 import org.eclipse.jgit.util.RawParseUtils;
-import org.joda.time.Duration;
-import org.pegdown.ast.RootNode;
 
 import java.io.IOException;
 import java.util.HashMap;
@@ -69,7 +67,7 @@
   // Generation of ETag logic. Bump this only if DocServlet logic changes
   // significantly enough to impact cached pages. Soy template and source
   // files are automatically hashed as part of the ETag.
-  private static final int ETAG_GEN = 4;
+  private static final int ETAG_GEN = 5;
 
   public DocServlet(GitilesAccess.Factory accessFactory, Renderer renderer) {
     super(renderer, accessFactory);
@@ -109,26 +107,21 @@
         return;
       }
 
-      Duration parseTimeout =
-          ConfigUtil.getDuration(
-              cfg, "markdown", null, "parseTimeout", Duration.standardSeconds(2));
       view = view.toBuilder().setPathPart(srcmd.path).build();
       int inputLimit = cfg.getInt("markdown", "inputLimit", 5 << 20);
-      RootNode doc =
-          GitilesMarkdown.parseFile(
-              parseTimeout, view, srcmd.path, srcmd.read(rw.getObjectReader(), inputLimit));
+      Node doc = GitilesMarkdown.parse(srcmd.read(rw.getObjectReader(), inputLimit));
       if (doc == null) {
         res.sendRedirect(GitilesView.show().copyFrom(view).toUrl());
         return;
       }
 
       String navPath = null;
-      RootNode nav = null;
+      String navMarkdown = null;
+      Node nav = null;
       if (navmd != null) {
         navPath = navmd.path;
-        nav =
-            GitilesMarkdown.parseFile(
-                parseTimeout, view, navPath, navmd.read(rw.getObjectReader(), inputLimit));
+        navMarkdown = navmd.read(rw.getObjectReader(), inputLimit);
+        nav = GitilesMarkdown.parse(navMarkdown);
         if (nav == null) {
           res.setStatus(SC_INTERNAL_SERVER_ERROR);
           return;
@@ -142,7 +135,7 @@
       }
 
       res.setHeader(HttpHeaders.ETAG, curEtag);
-      showDoc(req, res, view, cfg, img, navPath, nav, srcmd.path, doc);
+      showDoc(req, res, view, cfg, img, navPath, navMarkdown, nav, srcmd.path, doc);
     }
   }
 
@@ -179,19 +172,23 @@
       Config cfg,
       ImageLoader img,
       String navPath,
-      RootNode nav,
+      String navMarkdown,
+      Node nav,
       String docPath,
-      RootNode doc)
+      Node doc)
       throws IOException {
     Map<String, Object> data = new HashMap<>();
-    data.putAll(Navbar.bannerSoyData(view, img, nav));
+
+    MarkdownToHtml navHtml = new MarkdownToHtml(view, cfg, navPath);
+    data.putAll(Navbar.bannerSoyData(img, navHtml, navMarkdown, nav));
+    data.put("navbarHtml", navHtml.toSoyHtml(nav));
+
     data.put("pageTitle", MoreObjects.firstNonNull(MarkdownUtil.getTitle(doc), view.getPathPart()));
     if (view.getType() != GitilesView.Type.ROOTED_DOC) {
       data.put("sourceUrl", GitilesView.show().copyFrom(view).toUrl());
       data.put("logUrl", GitilesView.log().copyFrom(view).toUrl());
       data.put("blameUrl", GitilesView.blame().copyFrom(view).toUrl());
     }
-    data.put("navbarHtml", new MarkdownToHtml(view, cfg, navPath).toSoyHtml(nav));
     data.put("bodyHtml", new MarkdownToHtml(view, cfg, docPath).setImageLoader(img).toSoyHtml(doc));
 
     String analyticsId = cfg.getString("google", null, "analyticsId");
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/GitHubThematicBreakExtension.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/GitHubThematicBreakExtension.java
new file mode 100644
index 0000000..3e32990
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/GitHubThematicBreakExtension.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gitiles.doc;
+
+import org.commonmark.Extension;
+import org.commonmark.node.Block;
+import org.commonmark.node.ThematicBreak;
+import org.commonmark.parser.Parser;
+import org.commonmark.parser.Parser.ParserExtension;
+import org.commonmark.parser.block.AbstractBlockParser;
+import org.commonmark.parser.block.AbstractBlockParserFactory;
+import org.commonmark.parser.block.BlockContinue;
+import org.commonmark.parser.block.BlockStart;
+import org.commonmark.parser.block.MatchedBlockParser;
+import org.commonmark.parser.block.ParserState;
+
+/** Accepts just {@code --} for an {@code <hr>}. */
+public class GitHubThematicBreakExtension implements ParserExtension {
+  public static Extension create() {
+    return new GitHubThematicBreakExtension();
+  }
+
+  private GitHubThematicBreakExtension() {}
+
+  @Override
+  public void extend(Parser.Builder builder) {
+    builder.customBlockParserFactory(new BreakParserFactory());
+  }
+
+  private static class BreakParser extends AbstractBlockParser {
+    @Override
+    public Block getBlock() {
+      return new ThematicBreak();
+    }
+
+    @Override
+    public BlockContinue tryContinue(ParserState parserState) {
+      return BlockContinue.none();
+    }
+  }
+
+  private static class BreakParserFactory extends AbstractBlockParserFactory {
+    @Override
+    public BlockStart tryStart(ParserState state, MatchedBlockParser matched) {
+      if (state.getIndent() == 0) {
+        CharSequence line = state.getLine();
+        int s = state.getNextNonSpaceIndex();
+        if ("--".contentEquals(line.subSequence(s, line.length()))) {
+          return BlockStart.of(new BreakParser()).atIndex(line.length());
+        }
+      }
+      return BlockStart.none();
+    }
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/GitilesHtmlExtension.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/GitilesHtmlExtension.java
new file mode 100644
index 0000000..01d4714
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/GitilesHtmlExtension.java
@@ -0,0 +1,203 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gitiles.doc;
+
+import com.google.gitiles.doc.html.HtmlBuilder;
+
+import org.commonmark.Extension;
+import org.commonmark.node.AbstractVisitor;
+import org.commonmark.node.HardLineBreak;
+import org.commonmark.node.HtmlBlock;
+import org.commonmark.node.HtmlInline;
+import org.commonmark.node.Node;
+import org.commonmark.node.ThematicBreak;
+import org.commonmark.parser.Parser;
+import org.commonmark.parser.Parser.ParserExtension;
+import org.commonmark.parser.PostProcessor;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Convert some {@link HtmlInline} and {@link HtmlBlock} to safe types.
+ * <p>
+ * Gitiles style Markdown accepts only a very small subset of HTML that is safe
+ * for use within the document. This {@code PostProcessor} scans parsed nodes
+ * and converts them to safer types for rendering:
+ * <ul>
+ * <li>{@link HardLineBreak}
+ * <li>{@link ThematicBreak}
+ * <li>{@link NamedAnchor}
+ * <li>{@link IframeBlock}
+ * </ul>
+ */
+public class GitilesHtmlExtension implements ParserExtension {
+  private static final Pattern BREAK = Pattern.compile("<(hr|br)\\s*/?>", Pattern.CASE_INSENSITIVE);
+
+  private static final Pattern ANCHOR_OPEN =
+      Pattern.compile("<a\\s+name=([\"'])([^\"'\\s]+)\\1>", Pattern.CASE_INSENSITIVE);
+  private static final Pattern ANCHOR_CLOSE = Pattern.compile("</[aA]>");
+
+  private static final Pattern IFRAME_OPEN =
+      Pattern.compile("<iframe\\s+", Pattern.CASE_INSENSITIVE);
+  private static final Pattern IFRAME_CLOSE =
+      Pattern.compile("(?:/?>|</iframe>)", Pattern.CASE_INSENSITIVE);
+
+  private static final Pattern ATTR =
+      Pattern.compile(
+          "\\s+([a-z-]+)\\s*=\\s*([^\\s\"'=<>`]+|'[^']*'|\"[^\"]*\")", Pattern.CASE_INSENSITIVE);
+
+  public static Extension create() {
+    return new GitilesHtmlExtension();
+  }
+
+  private GitilesHtmlExtension() {}
+
+  @Override
+  public void extend(Parser.Builder builder) {
+    builder.postProcessor(new HtmlProcessor());
+  }
+
+  private static class HtmlProcessor implements PostProcessor {
+    @Override
+    public Node process(Node node) {
+      node.accept(new HtmlVisitor());
+      return node;
+    }
+  }
+
+  private static class HtmlVisitor extends AbstractVisitor {
+    @Override
+    public void visit(HtmlInline node) {
+      inline(node);
+    }
+
+    @Override
+    public void visit(HtmlBlock node) {
+      block(node);
+    }
+  }
+
+  private static void inline(HtmlInline curr) {
+    String html = curr.getLiteral();
+    Matcher m = BREAK.matcher(html);
+    if (m.matches()) {
+      switch (m.group(1).toLowerCase()) {
+        case "br":
+          curr.insertAfter(new HardLineBreak());
+          curr.unlink();
+          return;
+
+        case "hr":
+          curr.insertAfter(new ThematicBreak());
+          curr.unlink();
+          return;
+      }
+    }
+
+    m = ANCHOR_OPEN.matcher(html);
+    if (m.matches()) {
+      String name = m.group(2);
+      Node next = curr.getNext();
+
+      // HtmlInline{<a name="id">}HtmlInline{</a>}
+      if (isAnchorClose(next)) {
+        next.unlink();
+        next = curr.getNext();
+
+        NamedAnchor anchor = new NamedAnchor();
+        anchor.setName(name);
+        curr.insertAfter(anchor);
+        curr.unlink();
+        MarkdownUtil.trimPreviousWhitespace(anchor);
+        return;
+      }
+    }
+
+    // Discard potentially unsafe HtmlInline.
+    curr.unlink();
+  }
+
+  private static boolean isAnchorClose(Node n) {
+    return n instanceof HtmlInline && ANCHOR_CLOSE.matcher(((HtmlInline) n).getLiteral()).matches();
+  }
+
+  private static void block(HtmlBlock curr) {
+    String html = curr.getLiteral();
+    Matcher m = IFRAME_OPEN.matcher(html);
+    if (m.find()) {
+      int start = m.end() - 1 /* leave whitespace */;
+      m = IFRAME_CLOSE.matcher(html.substring(start));
+      if (m.find()) {
+        int end = start + m.start();
+        IframeBlock f = iframe(html.substring(start, end));
+        if (f != null) {
+          curr.insertAfter(f);
+          curr.unlink();
+          return;
+        }
+      }
+    }
+
+    // Discard potentially unsafe HtmlBlock.
+    curr.unlink();
+  }
+
+  private static IframeBlock iframe(String html) {
+    IframeBlock iframe = new IframeBlock();
+    Matcher m = ATTR.matcher(html);
+    while (m.find()) {
+      String att = m.group(1).toLowerCase();
+      String val = attributeValue(m);
+      switch (att) {
+        case "src":
+          if (!HtmlBuilder.isValidHttpUri(val)) {
+            return null;
+          }
+          iframe.src = val;
+          break;
+
+        case "height":
+          if (!HtmlBuilder.isValidCssDimension(val)) {
+            return null;
+          }
+          iframe.height = val;
+          break;
+
+        case "width":
+          if (!HtmlBuilder.isValidCssDimension(val)) {
+            return null;
+          }
+          iframe.width = val;
+          break;
+
+        case "frameborder":
+          iframe.border = !"0".equals(val);
+          break;
+      }
+    }
+    return iframe.src != null ? iframe : null;
+  }
+
+  private static String attributeValue(Matcher m) {
+    String val = m.group(2);
+    if (val.length() >= 2 && (val.charAt(0) == '\'' || val.charAt(0) == '"')) {
+      // Capture group includes the opening and closing quotation marks if the
+      // attribute value was quoted in the source document. Trim these.
+      val = val.substring(1, val.length() - 1);
+    }
+    return val;
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/GitilesMarkdown.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/GitilesMarkdown.java
index c417932..541343b 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/GitilesMarkdown.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/GitilesMarkdown.java
@@ -14,244 +14,35 @@
 
 package com.google.gitiles.doc;
 
-import com.google.common.base.Throwables;
-import com.google.gitiles.GitilesView;
+import com.google.common.collect.ImmutableList;
 
-import org.joda.time.Duration;
-import org.parboiled.Rule;
-import org.parboiled.common.Factory;
-import org.parboiled.errors.ParserRuntimeException;
-import org.parboiled.support.StringBuilderVar;
-import org.parboiled.support.Var;
-import org.pegdown.Parser;
-import org.pegdown.ParsingTimeoutException;
-import org.pegdown.PegDownProcessor;
-import org.pegdown.ast.Node;
-import org.pegdown.ast.RootNode;
-import org.pegdown.ast.SimpleNode;
-import org.pegdown.plugins.BlockPluginParser;
-import org.pegdown.plugins.InlinePluginParser;
-import org.pegdown.plugins.PegDownPlugins;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
+import org.commonmark.ext.autolink.AutolinkExtension;
+import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension;
+import org.commonmark.ext.gfm.tables.TablesExtension;
+import org.commonmark.node.Node;
+import org.commonmark.parser.Parser;
 
-import java.util.ArrayList;
-import java.util.List;
+/** Parses Gitiles style CommonMark Markdown. */
+public class GitilesMarkdown {
+  private static final Parser PARSER =
+      Parser.builder()
+          .extensions(
+              ImmutableList.of(
+                  AutolinkExtension.create(),
+                  BlockNoteExtension.create(),
+                  GitilesHtmlExtension.create(),
+                  GitHubThematicBreakExtension.create(),
+                  MultiColumnExtension.create(),
+                  NamedAnchorExtension.create(),
+                  SmartQuotedExtension.create(),
+                  StrikethroughExtension.create(),
+                  TablesExtension.create(),
+                  TocExtension.create()))
+          .build();
 
-/** Parses Gitiles extensions to markdown. */
-public class GitilesMarkdown extends Parser implements BlockPluginParser, InlinePluginParser {
-  private static final Logger log = LoggerFactory.getLogger(MarkdownUtil.class);
-
-  // SUPPRESS_ALL_HTML is enabled to permit hosting arbitrary user content
-  // while avoiding XSS style HTML, CSS and JavaScript injection attacks.
-  //
-  // HARDWRAPS is disabled to permit line wrapping within paragraphs to
-  // make the source file easier to read in 80 column terminals without
-  // this impacting the rendered formatting.
-  private static final int MD_OPTIONS = (ALL | SUPPRESS_ALL_HTML) & ~(HARDWRAPS);
-
-  public static RootNode parseFile(
-      Duration parseTimeout, GitilesView view, String path, String md) {
-    if (md == null) {
-      return null;
-    }
-
-    try {
-      try {
-        return newParser(parseTimeout).parseMarkdown(md.toCharArray());
-      } catch (ParserRuntimeException e) {
-        Throwables.propagateIfInstanceOf(e.getCause(), ParsingTimeoutException.class);
-        throw e;
-      }
-    } catch (ParsingTimeoutException e) {
-      log.error(
-          "timeout {} ms rendering {}/{} at {}",
-          parseTimeout.getMillis(),
-          view.getRepositoryName(),
-          path,
-          view.getRevision().getName());
-      return null;
-    }
+  public static Node parse(String md) {
+    return md != null ? PARSER.parse(md) : null;
   }
 
-  private static PegDownProcessor newParser(Duration parseDeadline) {
-    PegDownPlugins plugins =
-        new PegDownPlugins.Builder().withPlugin(GitilesMarkdown.class, parseDeadline).build();
-    return new PegDownProcessor(MD_OPTIONS, parseDeadline.getMillis(), plugins);
-  }
-
-  private final Duration parseTimeout;
-  private PegDownProcessor parser;
-
-  GitilesMarkdown(Duration parseTimeout) {
-    super(MD_OPTIONS, parseTimeout.getMillis(), DefaultParseRunnerProvider);
-    this.parseTimeout = parseTimeout;
-  }
-
-  @Override
-  public Rule[] blockPluginRules() {
-    return new Rule[] {
-      cols(), hr(), iframe(), note(), toc(),
-    };
-  }
-
-  @Override
-  public Rule[] inlinePluginRules() {
-    return new Rule[] {
-      namedAnchorHtmlStyle(), namedAnchorMarkdownExtensionStyle(),
-    };
-  }
-
-  public Rule toc() {
-    return NodeSequence(string("[TOC]"), push(new TocNode()));
-  }
-
-  public Rule hr() {
-    // GitHub flavor markdown recognizes "--" as a rule.
-    return NodeSequence(
-        NonindentSpace(),
-        string("--"),
-        zeroOrMore('-'),
-        Newline(),
-        oneOrMore(BlankLine()),
-        push(new SimpleNode(SimpleNode.Type.HRule)));
-  }
-
-  public Rule namedAnchorHtmlStyle() {
-    StringBuilderVar name = new StringBuilderVar();
-    return NodeSequence(
-        Sp(),
-        string("<a"),
-        Spn1(),
-        sequence(string("name="), attribute(name)),
-        Spn1(),
-        '>',
-        Spn1(),
-        string("</a>"),
-        push(new NamedAnchorNode(name.getString())));
-  }
-
-  public Rule namedAnchorMarkdownExtensionStyle() {
-    StringBuilderVar name = new StringBuilderVar();
-    return NodeSequence(
-        Sp(), string("{#"), anchorId(name), '}', push(new NamedAnchorNode(name.getString())));
-  }
-
-  public Rule anchorId(StringBuilderVar name) {
-    return sequence(zeroOrMore(testNot('}'), ANY), name.append(match()));
-  }
-
-  public Rule iframe() {
-    StringBuilderVar src = new StringBuilderVar();
-    StringBuilderVar h = new StringBuilderVar();
-    StringBuilderVar w = new StringBuilderVar();
-    StringBuilderVar b = new StringBuilderVar();
-    return NodeSequence(
-        string("<iframe"),
-        oneOrMore(
-            sequence(
-                Spn1(),
-                firstOf(
-                    sequence(string("src="), attribute(src)),
-                    sequence(string("height="), attribute(h)),
-                    sequence(string("width="), attribute(w)),
-                    sequence(string("frameborder="), attribute(b))))),
-        Spn1(),
-        '>',
-        Spn1(),
-        string("</iframe>"),
-        push(new IframeNode(src.getString(), h.getString(), w.getString(), b.getString())));
-  }
-
-  public Rule attribute(StringBuilderVar var) {
-    return firstOf(
-        sequence('"', zeroOrMore(testNot('"'), ANY), var.append(match()), '"'),
-        sequence('\'', zeroOrMore(testNot('\''), ANY), var.append(match()), '\''));
-  }
-
-  public Rule note() {
-    StringBuilderVar body = new StringBuilderVar();
-    return NodeSequence(
-        string("***"),
-        Sp(),
-        typeOfNote(),
-        Newline(),
-        oneOrMore(testNot(string("***"), Newline()), Line(body)),
-        string("***"),
-        Newline(),
-        push(new DivNode(popAsString(), parse(body))));
-  }
-
-  public Rule typeOfNote() {
-    return firstOf(
-        sequence(string("note"), push(match())),
-        sequence(string("promo"), push(match())),
-        sequence(string("aside"), push(match())));
-  }
-
-  @SuppressWarnings("unchecked")
-  public Rule cols() {
-    StringBuilderVar body = new StringBuilderVar();
-    return NodeSequence(
-        colsTag(),
-        columnWidths(),
-        Newline(),
-        oneOrMore(testNot(colsTag(), Newline()), Line(body)),
-        colsTag(),
-        Newline(),
-        push(new ColsNode((List<ColsNode.Column>) pop(), parse(body))));
-  }
-
-  public Rule colsTag() {
-    return string("|||---|||");
-  }
-
-  public Rule columnWidths() {
-    ListVar widths = new ListVar();
-    return sequence(
-        zeroOrMore(sequence(Sp(), optional(ch(',')), Sp(), columnWidth(widths))),
-        push(widths.get()));
-  }
-
-  public Rule columnWidth(ListVar widths) {
-    StringBuilderVar s = new StringBuilderVar();
-    return sequence(
-        optional(sequence(ch(':'), s.append(':'))),
-        oneOrMore(digit()),
-        s.append(match()),
-        widths.get().add(parse(s.get().toString())));
-  }
-
-  static ColsNode.Column parse(String spec) {
-    ColsNode.Column c = new ColsNode.Column();
-    if (spec.startsWith(":")) {
-      c.empty = true;
-      spec = spec.substring(1);
-    }
-    c.span = Integer.parseInt(spec, 10);
-    return c;
-  }
-
-  public List<Node> parse(StringBuilderVar body) {
-    // The pegdown code doesn't provide enough visibility to directly
-    // use its existing parsing rules. Recurse manually for inner text
-    // parsing within a block.
-    if (parser == null) {
-      parser = newParser(parseTimeout);
-    }
-    return parser.parseMarkdown(body.getChars()).getChildren();
-  }
-
-  public static class ListVar extends Var<List<Object>> {
-    @SuppressWarnings({"rawtypes", "unchecked"})
-    public ListVar() {
-      super(
-          new Factory() {
-            @Override
-            public Object create() {
-              return new ArrayList<>();
-            }
-          });
-    }
-  }
+  private GitilesMarkdown() {}
 }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/Visitor.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/IframeBlock.java
similarity index 71%
rename from gitiles-servlet/src/main/java/com/google/gitiles/doc/Visitor.java
rename to gitiles-servlet/src/main/java/com/google/gitiles/doc/IframeBlock.java
index 6c9f2f6..3ad0153 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/Visitor.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/IframeBlock.java
@@ -14,16 +14,12 @@
 
 package com.google.gitiles.doc;
 
-public interface Visitor extends org.pegdown.ast.Visitor {
-  void visit(ColsNode node);
+import org.commonmark.node.CustomBlock;
 
-  void visit(ColsNode.Column node);
-
-  void visit(DivNode node);
-
-  void visit(IframeNode node);
-
-  void visit(TocNode node);
-
-  void visit(NamedAnchorNode node);
+/** Parsed {@code <iframe>} tag. */
+public class IframeBlock extends CustomBlock {
+  String src;
+  String height;
+  String width;
+  boolean border;
 }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/IframeNode.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/IframeNode.java
deleted file mode 100644
index f4d1ca3..0000000
--- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/IframeNode.java
+++ /dev/null
@@ -1,47 +0,0 @@
-// Copyright 2015 Google Inc. All Rights Reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gitiles.doc;
-
-import com.google.common.base.Strings;
-
-import org.pegdown.ast.AbstractNode;
-import org.pegdown.ast.Node;
-
-import java.util.Collections;
-import java.util.List;
-
-class IframeNode extends AbstractNode {
-  final String src;
-  final String height;
-  final String width;
-  final boolean border;
-
-  IframeNode(String src, String height, String width, String border) {
-    this.src = src;
-    this.height = Strings.emptyToNull(height);
-    this.width = Strings.emptyToNull(width);
-    this.border = !"0".equals(border);
-  }
-
-  @Override
-  public void accept(org.pegdown.ast.Visitor visitor) {
-    ((Visitor) visitor).visit(this);
-  }
-
-  @Override
-  public List<Node> getChildren() {
-    return Collections.emptyList();
-  }
-}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownToHtml.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownToHtml.java
index 7b30e27..1b88d5b 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownToHtml.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownToHtml.java
@@ -14,7 +14,6 @@
 
 package com.google.gitiles.doc;
 
-import static com.google.common.base.Preconditions.checkState;
 import static com.google.gitiles.doc.MarkdownUtil.getInnerText;
 
 import com.google.common.annotations.VisibleForTesting;
@@ -23,69 +22,62 @@
 import com.google.gitiles.ThreadSafePrettifyParser;
 import com.google.gitiles.doc.html.HtmlBuilder;
 import com.google.template.soy.data.SanitizedContent;
-import com.google.template.soy.shared.restricted.EscapingConventions.FilterImageDataUri;
 import com.google.template.soy.shared.restricted.EscapingConventions.FilterNormalizeUri;
 
+import org.commonmark.ext.gfm.strikethrough.Strikethrough;
+import org.commonmark.ext.gfm.tables.TableBlock;
+import org.commonmark.ext.gfm.tables.TableBody;
+import org.commonmark.ext.gfm.tables.TableCell;
+import org.commonmark.ext.gfm.tables.TableHead;
+import org.commonmark.ext.gfm.tables.TableRow;
+import org.commonmark.node.Block;
+import org.commonmark.node.BlockQuote;
+import org.commonmark.node.BulletList;
+import org.commonmark.node.Code;
+import org.commonmark.node.CustomBlock;
+import org.commonmark.node.CustomNode;
+import org.commonmark.node.Document;
+import org.commonmark.node.Emphasis;
+import org.commonmark.node.FencedCodeBlock;
+import org.commonmark.node.HardLineBreak;
+import org.commonmark.node.Heading;
+import org.commonmark.node.HtmlBlock;
+import org.commonmark.node.HtmlInline;
+import org.commonmark.node.Image;
+import org.commonmark.node.IndentedCodeBlock;
+import org.commonmark.node.Link;
+import org.commonmark.node.ListBlock;
+import org.commonmark.node.ListItem;
+import org.commonmark.node.Node;
+import org.commonmark.node.OrderedList;
+import org.commonmark.node.Paragraph;
+import org.commonmark.node.SoftLineBreak;
+import org.commonmark.node.StrongEmphasis;
+import org.commonmark.node.Text;
+import org.commonmark.node.ThematicBreak;
+import org.commonmark.node.Visitor;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.util.StringUtils;
-import org.pegdown.ast.AbbreviationNode;
-import org.pegdown.ast.AutoLinkNode;
-import org.pegdown.ast.BlockQuoteNode;
-import org.pegdown.ast.BulletListNode;
-import org.pegdown.ast.CodeNode;
-import org.pegdown.ast.DefinitionListNode;
-import org.pegdown.ast.DefinitionNode;
-import org.pegdown.ast.DefinitionTermNode;
-import org.pegdown.ast.ExpImageNode;
-import org.pegdown.ast.ExpLinkNode;
-import org.pegdown.ast.HeaderNode;
-import org.pegdown.ast.HtmlBlockNode;
-import org.pegdown.ast.InlineHtmlNode;
-import org.pegdown.ast.ListItemNode;
-import org.pegdown.ast.MailLinkNode;
-import org.pegdown.ast.Node;
-import org.pegdown.ast.OrderedListNode;
-import org.pegdown.ast.ParaNode;
-import org.pegdown.ast.QuotedNode;
-import org.pegdown.ast.RefImageNode;
-import org.pegdown.ast.RefLinkNode;
-import org.pegdown.ast.ReferenceNode;
-import org.pegdown.ast.RootNode;
-import org.pegdown.ast.SimpleNode;
-import org.pegdown.ast.SpecialTextNode;
-import org.pegdown.ast.StrikeNode;
-import org.pegdown.ast.StrongEmphSuperNode;
-import org.pegdown.ast.SuperNode;
-import org.pegdown.ast.TableBodyNode;
-import org.pegdown.ast.TableCaptionNode;
-import org.pegdown.ast.TableCellNode;
-import org.pegdown.ast.TableColumnNode;
-import org.pegdown.ast.TableHeaderNode;
-import org.pegdown.ast.TableNode;
-import org.pegdown.ast.TableRowNode;
-import org.pegdown.ast.TextNode;
-import org.pegdown.ast.VerbatimNode;
-import org.pegdown.ast.WikiLinkNode;
 
 import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 import prettify.parser.Prettify;
 import syntaxhighlight.ParseResult;
 
 /**
- * Formats parsed markdown AST into HTML.
+ * Formats parsed Markdown AST into HTML.
  * <p>
- * Callers must create a new instance for each RootNode.
+ * Callers must create a new instance for each document.
  */
 public class MarkdownToHtml implements Visitor {
-  private final ReferenceMap references = new ReferenceMap();
   private final HtmlBuilder html = new HtmlBuilder();
   private final TocFormatter toc = new TocFormatter(html, 3);
   private final GitilesView view;
   private final Config cfg;
   private final String filePath;
   private ImageLoader imageLoader;
-  private TableState table;
   private boolean outputNamedAnchor = true;
 
   /**
@@ -111,7 +103,7 @@
   }
 
   /** Render the document AST to sanitized HTML. */
-  public SanitizedContent toSoyHtml(RootNode node) {
+  public SanitizedContent toSoyHtml(Node node) {
     if (node == null) {
       return null;
     }
@@ -122,41 +114,37 @@
   }
 
   @Override
-  public void visit(RootNode node) {
-    references.add(node);
+  public void visit(Document node) {
     visitChildren(node);
   }
 
-  @Override
-  public void visit(TocNode node) {
-    toc.format();
-  }
-
-  @Override
-  public void visit(DivNode node) {
-    html.open("div").attribute("class", node.getStyleName());
-    visitChildren(node);
+  private void visit(BlockNote node) {
+    html.open("div").attribute("class", node.getClassName());
+    Node f = node.getFirstChild();
+    if (f == node.getLastChild() && f instanceof Paragraph) {
+      // Avoid <p> inside <div> if there is only one <p>.
+      visitChildren(f);
+    } else {
+      visitChildren(node);
+    }
     html.close("div");
   }
 
-  @Override
-  public void visit(ColsNode node) {
+  private void visit(MultiColumnBlock node) {
     html.open("div").attribute("class", "cols");
     visitChildren(node);
     html.close("div");
   }
 
-  @Override
-  public void visit(ColsNode.Column node) {
-    if (1 <= node.span && node.span <= ColsNode.GRID_WIDTH) {
+  private void visit(MultiColumnBlock.Column node) {
+    if (1 <= node.span && node.span <= MultiColumnBlock.GRID_WIDTH) {
       html.open("div").attribute("class", "col-" + node.span);
       visitChildren(node);
       html.close("div");
     }
   }
 
-  @Override
-  public void visit(IframeNode node) {
+  private void visit(IframeBlock node) {
     if (HtmlBuilder.isValidHttpUri(node.src)
         && HtmlBuilder.isValidCssDimension(node.height)
         && HtmlBuilder.isValidCssDimension(node.width)
@@ -172,7 +160,7 @@
     }
   }
 
-  private boolean canRender(IframeNode node) {
+  private boolean canRender(IframeBlock node) {
     String[] ok = cfg.getStringList("markdown", null, "allowiframe");
     if (ok.length == 1 && StringUtils.toBooleanOrNull(ok[0]) == Boolean.TRUE) {
       return true;
@@ -186,7 +174,7 @@
   }
 
   @Override
-  public void visit(HeaderNode node) {
+  public void visit(Heading node) {
     outputNamedAnchor = false;
     String tag = "h" + node.getLevel();
     html.open(tag);
@@ -205,58 +193,67 @@
     outputNamedAnchor = true;
   }
 
-  @Override
-  public void visit(NamedAnchorNode node) {
+  private void visit(NamedAnchor node) {
     if (outputNamedAnchor) {
-      html.open("a").attribute("name", node.name).close("a");
+      html.open("a").attribute("name", node.getName()).close("a");
     }
   }
 
   @Override
-  public void visit(ParaNode node) {
-    wrapChildren("p", node);
+  public void visit(Paragraph node) {
+    if (isInTightList(node)) {
+      // Avoid unnecessary <p> tags within <ol><li> structures.
+      visitChildren(node);
+    } else {
+      wrapChildren("p", node);
+    }
+  }
+
+  private static boolean isInTightList(Paragraph c) {
+    Block b = c.getParent(); // b is probably a ListItem
+    if (b != null) {
+      Block a = b.getParent();
+      return a instanceof ListBlock && ((ListBlock) a).isTight();
+    }
+    return false;
   }
 
   @Override
-  public void visit(BlockQuoteNode node) {
+  public void visit(BlockQuote node) {
     wrapChildren("blockquote", node);
   }
 
   @Override
-  public void visit(OrderedListNode node) {
-    wrapChildren("ol", node);
+  public void visit(OrderedList node) {
+    html.open("ol");
+    if (node.getStartNumber() != 1) {
+      html.attribute("start", Integer.toString(node.getStartNumber()));
+    }
+    visitChildren(node);
+    html.close("ol");
   }
 
   @Override
-  public void visit(BulletListNode node) {
+  public void visit(BulletList node) {
     wrapChildren("ul", node);
   }
 
   @Override
-  public void visit(ListItemNode node) {
+  public void visit(ListItem node) {
     wrapChildren("li", node);
   }
 
   @Override
-  public void visit(DefinitionListNode node) {
-    wrapChildren("dl", node);
+  public void visit(FencedCodeBlock node) {
+    codeInPre(node.getInfo(), node.getLiteral());
   }
 
   @Override
-  public void visit(DefinitionNode node) {
-    wrapChildren("dd", node);
+  public void visit(IndentedCodeBlock node) {
+    codeInPre(null, node.getLiteral());
   }
 
-  @Override
-  public void visit(DefinitionTermNode node) {
-    wrapChildren("dt", node);
-  }
-
-  @Override
-  public void visit(VerbatimNode node) {
-    String lang = node.getType();
-    String text = node.getText();
-
+  private void codeInPre(String lang, String text) {
     html.open("pre").attribute("class", "code");
     text = printLeadingBlankLines(text);
     List<ParseResult> parsed = parse(lang, text);
@@ -308,67 +305,29 @@
   }
 
   @Override
-  public void visit(CodeNode node) {
-    wrapText("code", node);
+  public void visit(Code node) {
+    html.open("code").appendAndEscape(node.getLiteral()).close("code");
   }
 
   @Override
-  public void visit(StrikeNode node) {
-    wrapChildren("del", node);
+  public void visit(Emphasis node) {
+    wrapChildren("em", node);
   }
 
   @Override
-  public void visit(StrongEmphSuperNode node) {
-    if (node.isClosed()) {
-      wrapChildren(node.isStrong() ? "strong" : "em", node);
-    } else {
-      // Unclosed (or unmatched) sequence is plain text.
-      html.appendAndEscape(node.getChars());
-      visitChildren(node);
-    }
+  public void visit(StrongEmphasis node) {
+    wrapChildren("strong", node);
   }
 
   @Override
-  public void visit(AutoLinkNode node) {
-    String url = node.getText();
-    html.open("a").attribute("href", href(url)).appendAndEscape(url).close("a");
-  }
-
-  @Override
-  public void visit(MailLinkNode node) {
-    String addr = node.getText();
-    html.open("a").attribute("href", "mailto:" + addr).appendAndEscape(addr).close("a");
-  }
-
-  @Override
-  public void visit(WikiLinkNode node) {
-    String text = node.getText();
-    String path = text.replace(' ', '-') + ".md";
-    html.open("a").attribute("href", href(path)).appendAndEscape(text).close("a");
-  }
-
-  @Override
-  public void visit(ExpLinkNode node) {
-    html.open("a").attribute("href", href(node.url)).attribute("title", node.title);
+  public void visit(Link node) {
+    html.open("a")
+        .attribute("href", href(node.getDestination()))
+        .attribute("title", node.getTitle());
     visitChildren(node);
     html.close("a");
   }
 
-  @Override
-  public void visit(RefLinkNode node) {
-    ReferenceNode ref = references.get(node.referenceKey, getInnerText(node));
-    if (ref != null) {
-      html.open("a").attribute("href", href(ref.getUrl())).attribute("title", ref.getTitle());
-      visitChildren(node);
-      html.close("a");
-    } else {
-      // Treat a broken RefLink as plain text.
-      html.appendAndEscape("[");
-      visitChildren(node);
-      html.appendAndEscape("]");
-    }
-  }
-
   @VisibleForTesting
   String href(String target) {
     if (target.startsWith("#") || HtmlBuilder.isValidHttpUri(target)) {
@@ -422,28 +381,13 @@
   }
 
   @Override
-  public void visit(ExpImageNode node) {
+  public void visit(Image node) {
     html.open("img")
-        .attribute("src", resolveImageUrl(node.url))
-        .attribute("title", node.title)
+        .attribute("src", resolveImageUrl(node.getDestination()))
+        .attribute("title", node.getTitle())
         .attribute("alt", getInnerText(node));
   }
 
-  @Override
-  public void visit(RefImageNode node) {
-    String alt = getInnerText(node);
-    String url, title = alt;
-    ReferenceNode ref = references.get(node.referenceKey, alt);
-    if (ref != null) {
-      url = resolveImageUrl(ref.getUrl());
-      title = ref.getTitle();
-    } else {
-      // If reference is missing, insert a broken image.
-      url = FilterImageDataUri.INSTANCE.getInnocuousOutput();
-    }
-    html.open("img").attribute("src", url).attribute("title", title).attribute("alt", alt);
-  }
-
   private String resolveImageUrl(String url) {
     if (imageLoader == null
         || url.startsWith("https://")
@@ -454,166 +398,162 @@
     return imageLoader.loadImage(url);
   }
 
-  @Override
-  public void visit(TableNode node) {
-    table = new TableState(node);
+  public void visit(TableBlock node) {
     wrapChildren("table", node);
-    table = null;
   }
 
-  private void mustBeInsideTable(Node node) {
-    checkState(table != null, "%s must be in table", node);
-  }
-
-  @Override
-  public void visit(TableHeaderNode node) {
-    mustBeInsideTable(node);
-    table.inHeader = true;
-    wrapChildren("thead", node);
-    table.inHeader = false;
-  }
-
-  @Override
-  public void visit(TableBodyNode node) {
-    wrapChildren("tbody", node);
-  }
-
-  @Override
-  public void visit(TableCaptionNode node) {
-    wrapChildren("caption", node);
-  }
-
-  @Override
-  public void visit(TableRowNode node) {
-    mustBeInsideTable(node);
-    table.startRow();
+  private void visit(TableRow node) {
     wrapChildren("tr", node);
   }
 
-  @Override
-  public void visit(TableCellNode node) {
-    mustBeInsideTable(node);
-    String tag = table.inHeader ? "th" : "td";
-    html.open(tag).attribute("align", table.getAlign());
-    if (node.getColSpan() > 1) {
-      html.attribute("colspan", Integer.toString(node.getColSpan()));
+  private void visit(TableCell cell) {
+    String tag = cell.isHeader() ? "th" : "td";
+    html.open(tag);
+    TableCell.Alignment alignment = cell.getAlignment();
+    if (alignment != null) {
+      html.attribute("align", toHtml(alignment));
     }
-    visitChildren(node);
+    visitChildren(cell);
     html.close(tag);
-    table.done(node);
   }
 
-  @Override
-  public void visit(TableColumnNode node) {
-    // Not for output; should not be in the Visitor API.
+  private static String toHtml(TableCell.Alignment alignment) {
+    switch (alignment) {
+      case LEFT:
+        return "left";
+      case CENTER:
+        return "center";
+      case RIGHT:
+        return "right";
+      default:
+        throw new IllegalArgumentException("unsupported alignment " + alignment);
+    }
   }
 
-  @Override
-  public void visit(TextNode node) {
-    html.appendAndEscape(node.getText());
-    // TODO(sop) printWithAbbreviations
-  }
-
-  @Override
-  public void visit(SpecialTextNode node) {
-    html.appendAndEscape(node.getText());
-  }
-
-  @Override
-  public void visit(QuotedNode node) {
+  private void visit(SmartQuoted node) {
     switch (node.getType()) {
-      case DoubleAngle:
-        html.entity("&laquo;");
-        visitChildren(node);
-        html.entity("&raquo;");
-        break;
-      case Double:
+      case DOUBLE:
         html.entity("&ldquo;");
         visitChildren(node);
         html.entity("&rdquo;");
         break;
-      case Single:
+      case SINGLE:
         html.entity("&lsquo;");
         visitChildren(node);
         html.entity("&rsquo;");
         break;
       default:
-        checkState(false, "unsupported quote %s", node.getType());
+        throw new IllegalArgumentException("unsupported quote " + node.getType());
+    }
+  }
+
+  private static final Pattern PRETTY = Pattern.compile("('|[.]{3}|-{2,3})");
+
+  @Override
+  public void visit(Text node) {
+    String text = node.getLiteral();
+    Matcher pretty = PRETTY.matcher(text);
+    int i = 0;
+    while (pretty.find()) {
+      int s = pretty.start();
+      if (i < s) {
+        html.appendAndEscape(text.substring(i, s));
+      }
+      switch (pretty.group(0)) {
+        case "'":
+          html.entity("&rsquo;");
+          break;
+        case "...":
+          html.entity("&hellip;");
+          break;
+        case "--":
+          html.entity("&ndash;");
+          break;
+        case "---":
+          html.entity("&mdash;");
+          break;
+      }
+      i = pretty.end();
+    }
+    if (i < text.length()) {
+      html.appendAndEscape(text.substring(i));
     }
   }
 
   @Override
-  public void visit(SimpleNode node) {
-    switch (node.getType()) {
-      case Apostrophe:
-        html.entity("&rsquo;");
-        break;
-      case Ellipsis:
-        html.entity("&hellip;");
-        break;
-      case Emdash:
-        html.entity("&mdash;");
-        break;
-      case Endash:
-        html.entity("&ndash;");
-        break;
-      case HRule:
-        html.open("hr");
-        break;
-      case Linebreak:
-        html.open("br");
-        break;
-      case Nbsp:
-        html.entity("&nbsp;");
-        break;
-      default:
-        checkState(false, "unsupported node %s", node.getType());
-    }
+  public void visit(SoftLineBreak node) {
+    html.space();
   }
 
   @Override
-  public void visit(SuperNode node) {
-    visitChildren(node);
+  public void visit(HardLineBreak node) {
+    html.open("br");
   }
 
   @Override
-  public void visit(Node node) {
-    checkState(false, "node %s unsupported", node.getClass());
+  public void visit(ThematicBreak thematicBreak) {
+    html.open("hr");
   }
 
   @Override
-  public void visit(HtmlBlockNode node) {
-    // Drop all HTML nodes.
+  public void visit(HtmlInline node) {
+    // Discard all HTML.
   }
 
   @Override
-  public void visit(InlineHtmlNode node) {
-    // Drop all HTML nodes.
+  public void visit(HtmlBlock node) {
+    // Discard all HTML.
   }
 
-  @Override
-  public void visit(ReferenceNode node) {
-    // Reference nodes are not printed; they only declare an item.
-  }
-
-  @Override
-  public void visit(AbbreviationNode node) {
-    // Abbreviation nodes are not printed; they only declare an item.
-  }
-
-  private void wrapText(String tag, TextNode node) {
-    html.open(tag).appendAndEscape(node.getText()).close(tag);
-  }
-
-  private void wrapChildren(String tag, SuperNode node) {
+  private void wrapChildren(String tag, Node node) {
     html.open(tag);
     visitChildren(node);
     html.close(tag);
   }
 
   private void visitChildren(Node node) {
-    for (Node child : node.getChildren()) {
-      child.accept(this);
+    for (Node c = node.getFirstChild(); c != null; c = c.getNext()) {
+      c.accept(this);
+    }
+  }
+
+  @Override
+  public void visit(CustomNode node) {
+    if (node instanceof NamedAnchor) {
+      visit((NamedAnchor) node);
+    } else if (node instanceof SmartQuoted) {
+      visit((SmartQuoted) node);
+    } else if (node instanceof Strikethrough) {
+      wrapChildren("del", node);
+    } else if (node instanceof TableBody) {
+      wrapChildren("tbody", node);
+    } else if (node instanceof TableCell) {
+      visit((TableCell) node);
+    } else if (node instanceof TableHead) {
+      wrapChildren("thead", node);
+    } else if (node instanceof TableRow) {
+      visit((TableRow) node);
+    } else {
+      throw new IllegalArgumentException("cannot render " + node.getClass());
+    }
+  }
+
+  @Override
+  public void visit(CustomBlock node) {
+    if (node instanceof BlockNote) {
+      visit((BlockNote) node);
+    } else if (node instanceof IframeBlock) {
+      visit((IframeBlock) node);
+    } else if (node instanceof MultiColumnBlock) {
+      visit((MultiColumnBlock) node);
+    } else if (node instanceof MultiColumnBlock.Column) {
+      visit((MultiColumnBlock.Column) node);
+    } else if (node instanceof TableBlock) {
+      visit((TableBlock) node);
+    } else if (node instanceof TocBlock) {
+      toc.format();
+    } else {
+      throw new IllegalArgumentException("cannot render " + node.getClass());
     }
   }
 }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownUtil.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownUtil.java
index 5130216..6392cd0 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownUtil.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownUtil.java
@@ -14,21 +14,17 @@
 
 package com.google.gitiles.doc;
 
+import com.google.common.base.CharMatcher;
 import com.google.common.base.Strings;
 
-import org.pegdown.ast.HeaderNode;
-import org.pegdown.ast.Node;
-import org.pegdown.ast.TextNode;
+import org.commonmark.node.Heading;
+import org.commonmark.node.Node;
+import org.commonmark.node.Text;
 
 class MarkdownUtil {
-  /** Check if anchor URL is like {@code /top.md}. */
-  static boolean isAbsolutePathToMarkdown(String url) {
-    return url.length() >= 5 && url.charAt(0) == '/' && url.charAt(1) != '/' && url.endsWith(".md");
-  }
-
   /** Combine child nodes as string; this must be escaped for HTML. */
   static String getInnerText(Node node) {
-    if (node == null || node.getChildren().isEmpty()) {
+    if (node == null || node.getFirstChild() == null) {
       return null;
     }
 
@@ -38,25 +34,25 @@
   }
 
   private static void appendTextFromChildren(StringBuilder b, Node node) {
-    for (Node child : node.getChildren()) {
-      if (child instanceof TextNode) {
-        b.append(((TextNode) child).getText());
+    for (Node c = node.getFirstChild(); c != null; c = c.getNext()) {
+      if (c instanceof Text) {
+        b.append(((Text) c).getLiteral());
       } else {
-        appendTextFromChildren(b, child);
+        appendTextFromChildren(b, c);
       }
     }
   }
 
   static String getTitle(Node node) {
-    if (node instanceof HeaderNode) {
-      if (((HeaderNode) node).getLevel() == 1) {
+    if (node instanceof Heading) {
+      if (((Heading) node).getLevel() == 1) {
         return getInnerText(node);
       }
       return null;
     }
 
-    for (Node child : node.getChildren()) {
-      String title = getTitle(child);
+    for (Node c = node.getFirstChild(); c != null; c = c.getNext()) {
+      String title = getTitle(c);
       if (title != null) {
         return title;
       }
@@ -64,5 +60,14 @@
     return null;
   }
 
+  static void trimPreviousWhitespace(Node node) {
+    Node prev = node.getPrevious();
+    if (prev instanceof Text) {
+      Text prevText = (Text) prev;
+      String s = prevText.getLiteral();
+      prevText.setLiteral(CharMatcher.whitespace().trimTrailingFrom(s));
+    }
+  }
+
   private MarkdownUtil() {}
 }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/MultiColumnBlock.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/MultiColumnBlock.java
new file mode 100644
index 0000000..3651474
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/MultiColumnBlock.java
@@ -0,0 +1,35 @@
+// Copyright 2015 Google Inc. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gitiles.doc;
+
+import org.commonmark.node.CustomBlock;
+import org.commonmark.node.Heading;
+
+/**
+ * Multi-column layout delineated by {@code |||---|||}.
+ * <p>
+ * Each {@link Heading} or {@link BlockNote} within the layout begins a new
+ * {@link Column} in the HTML.
+ */
+public class MultiColumnBlock extends CustomBlock {
+  /** Grid is 12 columns wide. */
+  public static final int GRID_WIDTH = 12;
+
+  /** Column within a {@link MultiColumnBlock}. */
+  public static class Column extends CustomBlock {
+    int span;
+    boolean empty;
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/MultiColumnExtension.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/MultiColumnExtension.java
new file mode 100644
index 0000000..ef53fe3
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/MultiColumnExtension.java
@@ -0,0 +1,172 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gitiles.doc;
+
+import com.google.common.base.Splitter;
+import com.google.common.primitives.Ints;
+import com.google.gitiles.doc.MultiColumnBlock.Column;
+
+import org.commonmark.Extension;
+import org.commonmark.node.Block;
+import org.commonmark.node.Heading;
+import org.commonmark.node.Node;
+import org.commonmark.parser.Parser;
+import org.commonmark.parser.Parser.ParserExtension;
+import org.commonmark.parser.block.AbstractBlockParser;
+import org.commonmark.parser.block.AbstractBlockParserFactory;
+import org.commonmark.parser.block.BlockContinue;
+import org.commonmark.parser.block.BlockStart;
+import org.commonmark.parser.block.MatchedBlockParser;
+import org.commonmark.parser.block.ParserState;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/** CommonMark extension for multicolumn layouts. */
+public class MultiColumnExtension implements ParserExtension {
+  private static final String MARKER = "|||---|||";
+
+  public static Extension create() {
+    return new MultiColumnExtension();
+  }
+
+  private MultiColumnExtension() {}
+
+  @Override
+  public void extend(Parser.Builder builder) {
+    builder.customBlockParserFactory(new DivParserFactory());
+  }
+
+  private static class MultiColumnParser extends AbstractBlockParser {
+    private final MultiColumnBlock block = new MultiColumnBlock();
+    private final List<Column> cols;
+    private boolean done;
+
+    MultiColumnParser(String layout) {
+      List<String> specList = Splitter.on(',').trimResults().splitToList(layout);
+      cols = new ArrayList<>(specList.size());
+      for (String spec : specList) {
+        cols.add(parseColumn(spec));
+      }
+    }
+
+    private MultiColumnBlock.Column parseColumn(String spec) {
+      MultiColumnBlock.Column col = new MultiColumnBlock.Column();
+      if (spec.startsWith(":")) {
+        col.empty = true;
+        spec = spec.substring(1);
+      }
+
+      Integer width = Ints.tryParse(spec, 10);
+      if (width != null) {
+        col.span = width;
+      }
+      return col;
+    }
+
+    @Override
+    public Block getBlock() {
+      return block;
+    }
+
+    @Override
+    public BlockContinue tryContinue(ParserState state) {
+      if (done) {
+        return BlockContinue.none();
+      }
+      if (state.getIndent() == 0) {
+        int s = state.getNextNonSpaceIndex();
+        CharSequence line = state.getLine();
+        if (MARKER.contentEquals(line.subSequence(s, line.length()))) {
+          done = true;
+          return BlockContinue.atIndex(line.length());
+        }
+      }
+      return BlockContinue.atIndex(state.getIndex());
+    }
+
+    @Override
+    public void closeBlock() {
+      splitChildren();
+      rebalanceSpans();
+
+      for (MultiColumnBlock.Column c : cols) {
+        block.appendChild(c);
+      }
+    }
+
+    private void splitChildren() {
+      int colIdx = 0;
+      Column col = null;
+      Node next = null;
+
+      for (Node child = block.getFirstChild(); child != null; child = next) {
+        if (col == null || child instanceof Heading || child instanceof BlockNote) {
+          for (; ; ) {
+            if (colIdx == cols.size()) {
+              cols.add(new Column());
+            }
+            col = cols.get(colIdx++);
+            if (!col.empty) {
+              break;
+            }
+          }
+        }
+        next = child.getNext();
+        col.appendChild(child);
+      }
+    }
+
+    private void rebalanceSpans() {
+      int remaining = MultiColumnBlock.GRID_WIDTH;
+      for (int i = 0; i < cols.size(); i++) {
+        Column col = cols.get(i);
+        if (col.span <= 0 || col.span > MultiColumnBlock.GRID_WIDTH) {
+          col.span = remaining / (cols.size() - i);
+        }
+        remaining = Math.max(0, remaining - col.span);
+      }
+    }
+
+    @Override
+    public boolean isContainer() {
+      return true;
+    }
+
+    @Override
+    public boolean canContain(Block block) {
+      return !(block instanceof MultiColumnBlock);
+    }
+  }
+
+  private static class DivParserFactory extends AbstractBlockParserFactory {
+    @Override
+    public BlockStart tryStart(ParserState state, MatchedBlockParser matched) {
+      if (state.getIndent() > 0) {
+        return BlockStart.none();
+      }
+
+      int s = state.getNextNonSpaceIndex();
+      CharSequence line = state.getLine();
+      CharSequence text = line.subSequence(s, line.length());
+      if (text.length() >= MARKER.length()
+          && MARKER.contentEquals(text.subSequence(0, MARKER.length()))) {
+        String layout = text.subSequence(MARKER.length(), line.length()).toString().trim();
+        return BlockStart.of(new MultiColumnParser(layout)).atIndex(line.length());
+      }
+      return BlockStart.none();
+    }
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/Visitor.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/NamedAnchor.java
similarity index 61%
copy from gitiles-servlet/src/main/java/com/google/gitiles/doc/Visitor.java
copy to gitiles-servlet/src/main/java/com/google/gitiles/doc/NamedAnchor.java
index 6c9f2f6..5e93c55 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/Visitor.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/NamedAnchor.java
@@ -4,7 +4,7 @@
 // you may not use this file except in compliance with the License.
 // You may obtain a copy of the License at
 //
-//     http://www.apache.org/licenses/LICENSE-2.0
+// http://www.apache.org/licenses/LICENSE-2.0
 //
 // Unless required by applicable law or agreed to in writing, software
 // distributed under the License is distributed on an "AS IS" BASIS,
@@ -14,16 +14,18 @@
 
 package com.google.gitiles.doc;
 
-public interface Visitor extends org.pegdown.ast.Visitor {
-  void visit(ColsNode node);
+import org.commonmark.node.CustomNode;
+import org.commonmark.node.Heading;
 
-  void visit(ColsNode.Column node);
+/** A {@code <a name="...">} tag, usually inside a {@link Heading}. */
+public class NamedAnchor extends CustomNode {
+  private String name;
 
-  void visit(DivNode node);
+  public String getName() {
+    return name;
+  }
 
-  void visit(IframeNode node);
-
-  void visit(TocNode node);
-
-  void visit(NamedAnchorNode node);
+  public void setName(String name) {
+    this.name = name;
+  }
 }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/NamedAnchorExtension.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/NamedAnchorExtension.java
new file mode 100644
index 0000000..b72b202
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/NamedAnchorExtension.java
@@ -0,0 +1,84 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gitiles.doc;
+
+import org.commonmark.Extension;
+import org.commonmark.node.Node;
+import org.commonmark.node.Text;
+import org.commonmark.parser.DelimiterProcessor;
+import org.commonmark.parser.Parser;
+import org.commonmark.parser.Parser.ParserExtension;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/** Parses <code>{#foo}</code> into {@link NamedAnchor}. */
+public class NamedAnchorExtension implements ParserExtension {
+  public static Extension create() {
+    return new NamedAnchorExtension();
+  }
+
+  private NamedAnchorExtension() {}
+
+  @Override
+  public void extend(Parser.Builder builder) {
+    builder.customDelimiterProcessor(new Processor());
+  }
+
+  private static class Processor implements DelimiterProcessor {
+    private static final Pattern ID = Pattern.compile("#([^\\s}]+)");
+
+    @Override
+    public char getOpeningDelimiterChar() {
+      return '{';
+    }
+
+    @Override
+    public char getClosingDelimiterChar() {
+      return '}';
+    }
+
+    @Override
+    public int getMinDelimiterCount() {
+      return 1;
+    }
+
+    @Override
+    public int getDelimiterUse(int openerCount, int closerCount) {
+      return 1;
+    }
+
+    @Override
+    public void process(Text opener, Text closer, int delimiterUse) {
+      Node content = opener.getNext();
+      if (content instanceof Text && content.getNext() == closer) {
+        Matcher m = ID.matcher(((Text) content).getLiteral());
+        if (m.matches()) {
+          content.unlink();
+
+          NamedAnchor anchor = new NamedAnchor();
+          anchor.setName(m.group(1));
+          opener.insertAfter(anchor);
+          MarkdownUtil.trimPreviousWhitespace(opener);
+          return;
+        }
+      }
+
+      // If its not exactly one well formed Text node; restore the delimiter text.
+      opener.insertAfter(new Text("{"));
+      closer.insertBefore(new Text("}"));
+    }
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/NamedAnchorNode.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/NamedAnchorNode.java
deleted file mode 100644
index d5c4f8c..0000000
--- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/NamedAnchorNode.java
+++ /dev/null
@@ -1,39 +0,0 @@
-// Copyright 2015 Google Inc. All Rights Reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gitiles.doc;
-
-import org.pegdown.ast.AbstractNode;
-import org.pegdown.ast.Node;
-
-import java.util.Collections;
-import java.util.List;
-
-class NamedAnchorNode extends AbstractNode {
-  final String name;
-
-  NamedAnchorNode(String name) {
-    this.name = name;
-  }
-
-  @Override
-  public void accept(org.pegdown.ast.Visitor visitor) {
-    ((Visitor) visitor).visit(this);
-  }
-
-  @Override
-  public List<Node> getChildren() {
-    return Collections.emptyList();
-  }
-}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/Navbar.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/Navbar.java
index aa8e0eb..c1ed246 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/Navbar.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/Navbar.java
@@ -14,22 +14,24 @@
 
 package com.google.gitiles.doc;
 
-import com.google.gitiles.GitilesView;
 import com.google.gitiles.doc.html.HtmlBuilder;
 import com.google.template.soy.shared.restricted.EscapingConventions.FilterImageDataUri;
 import com.google.template.soy.shared.restricted.Sanitizers;
 
-import org.pegdown.ast.HeaderNode;
-import org.pegdown.ast.Node;
-import org.pegdown.ast.ReferenceNode;
-import org.pegdown.ast.RootNode;
+import org.commonmark.node.Heading;
+import org.commonmark.node.Node;
 
 import java.util.HashMap;
-import java.util.Iterator;
 import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 class Navbar {
-  static Map<String, Object> bannerSoyData(GitilesView view, ImageLoader img, RootNode nav) {
+  private static final Pattern REF_LINK =
+      Pattern.compile("^\\[(logo|home)\\]:\\s*(.+)$", Pattern.MULTILINE | Pattern.CASE_INSENSITIVE);
+
+  static Map<String, Object> bannerSoyData(
+      ImageLoader img, MarkdownToHtml toHtml, String navMarkdown, Node nav) {
     Map<String, Object> data = new HashMap<>();
     data.put("siteTitle", null);
     data.put("logoUrl", null);
@@ -39,42 +41,50 @@
       return data;
     }
 
-    for (Iterator<Node> i = nav.getChildren().iterator(); i.hasNext(); ) {
-      Node n = i.next();
-      if (n instanceof HeaderNode) {
-        HeaderNode h = (HeaderNode) n;
+    for (Node c = nav.getFirstChild(); c != null; c = c.getNext()) {
+      if (c instanceof Heading) {
+        Heading h = (Heading) c;
         if (h.getLevel() == 1) {
           data.put("siteTitle", MarkdownUtil.getInnerText(h));
-          i.remove();
+          h.unlink();
           break;
         }
       }
     }
 
-    for (ReferenceNode r : nav.getReferences()) {
-      String key = MarkdownUtil.getInnerText(r);
-      String url = r.getUrl();
-      if ("logo".equalsIgnoreCase(key)) {
-        Object src;
-        if (HtmlBuilder.isValidHttpUri(url)) {
-          src = url;
-        } else if (HtmlBuilder.isImageDataUri(url)) {
-          src = Sanitizers.filterImageDataUri(url);
-        } else if (img != null) {
-          src = Sanitizers.filterImageDataUri(img.loadImage(url));
-        } else {
-          src = FilterImageDataUri.INSTANCE.getInnocuousOutput();
-        }
-        data.put("logoUrl", src);
-      } else if ("home".equalsIgnoreCase(key)) {
-        if (MarkdownUtil.isAbsolutePathToMarkdown(url)) {
-          url = GitilesView.doc().copyFrom(view).setPathPart(url).toUrl();
-        }
-        data.put("homeUrl", url);
+    Matcher m = REF_LINK.matcher(navMarkdown);
+    while (m.find()) {
+      String key = m.group(1).toLowerCase();
+      String url = m.group(2).trim();
+      switch (key) {
+        case "logo":
+          data.put("logoUrl", toImgSrc(img, url));
+          break;
+
+        case "home":
+          data.put("homeUrl", toHtml.href(url));
+          break;
       }
     }
+
     return data;
   }
 
+  private static Object toImgSrc(ImageLoader img, String url) {
+    if (HtmlBuilder.isValidHttpUri(url)) {
+      return url;
+    }
+
+    if (HtmlBuilder.isImageDataUri(url)) {
+      return Sanitizers.filterImageDataUri(url);
+    }
+
+    if (img != null) {
+      return Sanitizers.filterImageDataUri(img.loadImage(url));
+    }
+
+    return FilterImageDataUri.INSTANCE.getInnocuousOutput();
+  }
+
   private Navbar() {}
 }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/ReferenceMap.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/ReferenceMap.java
deleted file mode 100644
index 34800a8..0000000
--- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/ReferenceMap.java
+++ /dev/null
@@ -1,64 +0,0 @@
-// Copyright 2015 Google Inc. All Rights Reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gitiles.doc;
-
-import static com.google.gitiles.doc.MarkdownUtil.getInnerText;
-
-import org.pegdown.ast.ReferenceNode;
-import org.pegdown.ast.RootNode;
-import org.pegdown.ast.SuperNode;
-
-import java.util.HashMap;
-import java.util.Map;
-
-class ReferenceMap {
-  private final Map<String, ReferenceNode> references = new HashMap<>();
-
-  void add(RootNode node) {
-    for (ReferenceNode ref : node.getReferences()) {
-      String id = getInnerText(ref);
-      references.put(key(id), ref);
-    }
-  }
-
-  ReferenceNode get(SuperNode keyNode, String text) {
-    String id = keyNode != null ? getInnerText(keyNode) : text;
-    if (id == null || id.isEmpty()) {
-      return null;
-    }
-    return references.get(key(id));
-  }
-
-  private static String key(String in) {
-    // Strip whitespace and normalize to lower case. Pegdown's default
-    // HTML formatter also applies this type of normalization to make
-    // it easier for document authors to reference links. Links should
-    // be case insensitive to allow for easier formatting of title case
-    // in prose vs. in the reference table, especially if a link is used
-    // both at the start of a sentence and later in the middle of sentence.
-    //
-    // Whitespace stripping is also performed by pegdown's default code.
-    // This allows references to to be declared as "foobar" but prose to
-    // mention it as "Foo Bar".
-    StringBuilder r = new StringBuilder(in.length());
-    for (int i = 0; i < in.length(); i++) {
-      char c = in.charAt(i);
-      if (!Character.isWhitespace(c)) {
-        r.append(Character.toLowerCase(c));
-      }
-    }
-    return r.toString();
-  }
-}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/SmartQuoted.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/SmartQuoted.java
new file mode 100644
index 0000000..001b830
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/SmartQuoted.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gitiles.doc;
+
+import org.commonmark.node.CustomNode;
+
+/** SmartQuotes around text. */
+public class SmartQuoted extends CustomNode {
+  public enum Type {
+    DOUBLE,
+    SINGLE;
+  }
+
+  private Type type;
+
+  public Type getType() {
+    return type;
+  }
+
+  public void setType(Type type) {
+    this.type = type;
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/SmartQuotedExtension.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/SmartQuotedExtension.java
new file mode 100644
index 0000000..92c271e
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/SmartQuotedExtension.java
@@ -0,0 +1,88 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gitiles.doc;
+
+import static com.google.gitiles.doc.SmartQuoted.Type.*;
+
+import com.google.gitiles.doc.SmartQuoted.Type;
+
+import org.commonmark.Extension;
+import org.commonmark.node.Node;
+import org.commonmark.node.Text;
+import org.commonmark.parser.DelimiterProcessor;
+import org.commonmark.parser.Parser;
+import org.commonmark.parser.Parser.ParserExtension;
+
+/** Uses smart quotes for ' and ". */
+public class SmartQuotedExtension implements ParserExtension {
+  public static Extension create() {
+    return new SmartQuotedExtension();
+  }
+
+  private SmartQuotedExtension() {}
+
+  @Override
+  public void extend(Parser.Builder builder) {
+    builder.customDelimiterProcessor(new QuotedProcessor(SINGLE, '\''));
+    builder.customDelimiterProcessor(new QuotedProcessor(DOUBLE, '"'));
+  }
+
+  private static void quote(Type type, Text opener, Text closer) {
+    SmartQuoted quote = new SmartQuoted();
+    quote.setType(type);
+    for (Node t = opener.getNext(); t != null && t != closer; ) {
+      Node next = t.getNext();
+      quote.appendChild(t);
+      t = next;
+    }
+    opener.insertAfter(quote);
+  }
+
+  /** Parses single and double quoted strings for smart quotes. */
+  private static class QuotedProcessor implements DelimiterProcessor {
+    private final SmartQuoted.Type type;
+    private final char delim;
+
+    QuotedProcessor(SmartQuoted.Type type, char open) {
+      this.type = type;
+      this.delim = open;
+    }
+
+    @Override
+    public char getOpeningDelimiterChar() {
+      return delim;
+    }
+
+    @Override
+    public char getClosingDelimiterChar() {
+      return delim;
+    }
+
+    @Override
+    public int getMinDelimiterCount() {
+      return 1;
+    }
+
+    @Override
+    public int getDelimiterUse(int openerCount, int closerCount) {
+      return 1;
+    }
+
+    @Override
+    public void process(Text opener, Text closer, int delimiterUse) {
+      quote(type, opener, closer);
+    }
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/TableState.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/TableState.java
deleted file mode 100644
index 396a86b..0000000
--- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/TableState.java
+++ /dev/null
@@ -1,58 +0,0 @@
-// Copyright 2015 Google Inc. All Rights Reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gitiles.doc;
-
-import org.pegdown.ast.TableCellNode;
-import org.pegdown.ast.TableColumnNode;
-import org.pegdown.ast.TableNode;
-
-import java.util.List;
-
-class TableState {
-  private final List<TableColumnNode> columns;
-
-  boolean inHeader;
-  int column;
-
-  TableState(TableNode node) {
-    columns = node.getColumns();
-  }
-
-  void startRow() {
-    column = 0;
-  }
-
-  String getAlign() {
-    int pos = Math.min(column, columns.size() - 1);
-    TableColumnNode c = columns.get(pos);
-    switch (c.getAlignment()) {
-      case None:
-        return null;
-      case Left:
-        return "left";
-      case Right:
-        return "right";
-      case Center:
-        return "center";
-      default:
-        throw new IllegalStateException(
-            String.format("unsupported alignment %s on column %d", c.getAlignment(), pos));
-    }
-  }
-
-  void done(TableCellNode cell) {
-    column += cell.getColSpan();
-  }
-}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/Visitor.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/TocBlock.java
similarity index 71%
copy from gitiles-servlet/src/main/java/com/google/gitiles/doc/Visitor.java
copy to gitiles-servlet/src/main/java/com/google/gitiles/doc/TocBlock.java
index 6c9f2f6..6a52720 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/Visitor.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/TocBlock.java
@@ -14,16 +14,7 @@
 
 package com.google.gitiles.doc;
 
-public interface Visitor extends org.pegdown.ast.Visitor {
-  void visit(ColsNode node);
+import org.commonmark.node.CustomBlock;
 
-  void visit(ColsNode.Column node);
-
-  void visit(DivNode node);
-
-  void visit(IframeNode node);
-
-  void visit(TocNode node);
-
-  void visit(NamedAnchorNode node);
-}
+/** Block node {@code [TOC]} to display table of contents. */
+public class TocBlock extends CustomBlock {}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/TocExtension.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/TocExtension.java
new file mode 100644
index 0000000..34db0cf
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/TocExtension.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gitiles.doc;
+
+import org.commonmark.Extension;
+import org.commonmark.node.Block;
+import org.commonmark.parser.Parser;
+import org.commonmark.parser.Parser.ParserExtension;
+import org.commonmark.parser.block.AbstractBlockParser;
+import org.commonmark.parser.block.AbstractBlockParserFactory;
+import org.commonmark.parser.block.BlockContinue;
+import org.commonmark.parser.block.BlockStart;
+import org.commonmark.parser.block.MatchedBlockParser;
+import org.commonmark.parser.block.ParserState;
+
+/** CommonMark extension for {@code [TOC]}. */
+public class TocExtension implements ParserExtension {
+  public static Extension create() {
+    return new TocExtension();
+  }
+
+  private TocExtension() {}
+
+  @Override
+  public void extend(Parser.Builder builder) {
+    builder.customBlockParserFactory(new TocParserFactory());
+  }
+
+  private static class TocParser extends AbstractBlockParser {
+    private final TocBlock block = new TocBlock();
+
+    @Override
+    public Block getBlock() {
+      return block;
+    }
+
+    @Override
+    public BlockContinue tryContinue(ParserState parserState) {
+      return BlockContinue.none();
+    }
+  }
+
+  private static class TocParserFactory extends AbstractBlockParserFactory {
+    @Override
+    public BlockStart tryStart(ParserState state, MatchedBlockParser matched) {
+      if (state.getIndent() == 0) {
+        CharSequence line = state.getLine();
+        int s = state.getNextNonSpaceIndex();
+        if ("[TOC]".contentEquals(line.subSequence(s, line.length()))) {
+          return BlockStart.of(new TocParser()).atIndex(line.length());
+        }
+      }
+      return BlockStart.none();
+    }
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/TocFormatter.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/TocFormatter.java
index cb82f54..9c415a8 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/TocFormatter.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/TocFormatter.java
@@ -21,9 +21,8 @@
 import com.google.gitiles.doc.html.HtmlBuilder;
 
 import org.apache.commons.lang3.StringUtils;
-import org.pegdown.ast.HeaderNode;
-import org.pegdown.ast.Node;
-import org.pegdown.ast.RootNode;
+import org.commonmark.node.Heading;
+import org.commonmark.node.Node;
 
 import java.util.ArrayDeque;
 import java.util.ArrayList;
@@ -38,8 +37,8 @@
   private final int maxLevel;
 
   private int countH1;
-  private List<HeaderNode> outline;
-  private Map<HeaderNode, String> ids;
+  private List<Heading> outline;
+  private Map<Heading, String> ids;
 
   private int level;
 
@@ -48,21 +47,21 @@
     this.maxLevel = maxLevel;
   }
 
-  void setRoot(RootNode doc) {
+  void setRoot(Node doc) {
     outline = new ArrayList<>();
     Multimap<String, TocEntry> entries = ArrayListMultimap.create(16, 4);
-    scan(doc, entries, new ArrayDeque<HeaderNode>());
+    scan(doc, entries, new ArrayDeque<Heading>());
     ids = generateIds(entries);
   }
 
-  private boolean include(HeaderNode h) {
+  private boolean include(Heading h) {
     if (h.getLevel() == 1) {
       return countH1 > 1;
     }
     return h.getLevel() <= maxLevel;
   }
 
-  String idFromHeader(HeaderNode header) {
+  String idFromHeader(Heading header) {
     return ids.get(header);
   }
 
@@ -79,7 +78,7 @@
         .open("div")
         .attribute("class", "toc-aux")
         .open("ul");
-    for (HeaderNode header : outline) {
+    for (Heading header : outline) {
       outline(header);
     }
     while (level >= startLevel) {
@@ -89,7 +88,7 @@
     html.close("div").close("div");
   }
 
-  private void outline(HeaderNode h) {
+  private void outline(Heading h) {
     if (!include(h)) {
       return;
     }
@@ -116,18 +115,17 @@
         .close("li");
   }
 
-  private void scan(Node node, Multimap<String, TocEntry> entries, Deque<HeaderNode> stack) {
-    if (node instanceof HeaderNode) {
-      scan((HeaderNode) node, entries, stack);
+  private void scan(Node node, Multimap<String, TocEntry> entries, Deque<Heading> stack) {
+    if (node instanceof Heading) {
+      scan((Heading) node, entries, stack);
     } else {
-      for (Node child : node.getChildren()) {
-        scan(child, entries, stack);
+      for (Node c = node.getFirstChild(); c != null; c = c.getNext()) {
+        scan(c, entries, stack);
       }
     }
   }
 
-  private void scan(
-      HeaderNode header, Multimap<String, TocEntry> entries, Deque<HeaderNode> stack) {
+  private void scan(Heading header, Multimap<String, TocEntry> entries, Deque<Heading> stack) {
     if (header.getLevel() == 1) {
       countH1++;
     }
@@ -135,9 +133,9 @@
       stack.removeLast();
     }
 
-    NamedAnchorNode node = findAnchor(header);
+    NamedAnchor node = findAnchor(header);
     if (node != null) {
-      entries.put(node.name, new TocEntry(stack, header, false, node.name));
+      entries.put(node.getName(), new TocEntry(stack, header, false, node.getName()));
       stack.add(header);
       outline.add(header);
       return;
@@ -152,12 +150,12 @@
     }
   }
 
-  private static NamedAnchorNode findAnchor(Node node) {
-    for (Node child : node.getChildren()) {
-      if (child instanceof NamedAnchorNode) {
-        return (NamedAnchorNode) child;
+  private static NamedAnchor findAnchor(Node node) {
+    for (Node c = node.getFirstChild(); c != null; c = c.getNext()) {
+      if (c instanceof NamedAnchor) {
+        return (NamedAnchor) c;
       }
-      NamedAnchorNode anchor = findAnchor(child);
+      NamedAnchor anchor = findAnchor(c);
       if (anchor != null) {
         return anchor;
       }
@@ -165,7 +163,7 @@
     return null;
   }
 
-  private Map<HeaderNode, String> generateIds(Multimap<String, TocEntry> entries) {
+  private Map<Heading, String> generateIds(Multimap<String, TocEntry> entries) {
     Multimap<String, TocEntry> tmp = ArrayListMultimap.create(entries.size(), 2);
     for (Collection<TocEntry> headers : entries.asMap().values()) {
       if (headers.size() == 1) {
@@ -182,7 +180,7 @@
         }
 
         StringBuilder b = new StringBuilder();
-        for (HeaderNode p : entry.path) {
+        for (Heading p : entry.path) {
           if (p.getLevel() > 1 || countH1 > 1) {
             String text = MarkdownUtil.getInnerText(p);
             if (text != null) {
@@ -196,7 +194,7 @@
       }
     }
 
-    Map<HeaderNode, String> ids = Maps.newHashMapWithExpectedSize(tmp.size());
+    Map<Heading, String> ids = Maps.newHashMapWithExpectedSize(tmp.size());
     for (Collection<TocEntry> headers : tmp.asMap().values()) {
       if (headers.size() == 1) {
         TocEntry entry = Iterables.getOnlyElement(headers);
@@ -212,13 +210,13 @@
   }
 
   private static class TocEntry {
-    final HeaderNode[] path;
-    final HeaderNode header;
+    final Heading[] path;
+    final Heading header;
     final boolean generated;
     String id;
 
-    TocEntry(Deque<HeaderNode> stack, HeaderNode header, boolean generated, String id) {
-      this.path = stack.toArray(new HeaderNode[stack.size()]);
+    TocEntry(Deque<Heading> stack, Heading header, boolean generated, String id) {
+      this.path = stack.toArray(new Heading[stack.size()]);
       this.header = header;
       this.generated = generated;
       this.id = id;
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/TocNode.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/TocNode.java
deleted file mode 100644
index 33dd31c..0000000
--- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/TocNode.java
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright 2015 Google Inc. All Rights Reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gitiles.doc;
-
-import org.pegdown.ast.AbstractNode;
-import org.pegdown.ast.Node;
-
-import java.util.Collections;
-import java.util.List;
-
-/** Block node {@code [TOC]} to display table of contents. */
-public class TocNode extends AbstractNode {
-  @Override
-  public void accept(org.pegdown.ast.Visitor visitor) {
-    ((Visitor) visitor).visit(this);
-  }
-
-  @Override
-  public List<Node> getChildren() {
-    return Collections.emptyList();
-  }
-}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/html/HtmlBuilder.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/html/HtmlBuilder.java
index 89611da..9639176 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/html/HtmlBuilder.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/html/HtmlBuilder.java
@@ -138,6 +138,8 @@
       // allow
     } else if ("name".equals(att) && "a".equals(tag)) {
       // allow
+    } else if ("start".equals(att) && "ol".equals(tag)) {
+      // allow
     } else if (("colspan".equals(att) || "align".equals(att))
         && ("td".equals(tag) || "th".equals(tag))) {
       // allow
@@ -205,6 +207,13 @@
     }
   }
 
+  /** Append a space outside of an element. */
+  public HtmlBuilder space() {
+    finishActiveTag();
+    htmlBuf.append(' ');
+    return this;
+  }
+
   private static final Pattern HTML_ENTITY = Pattern.compile("&[a-z]+;");
 
   /** Append constant entity reference like {@code &nbsp;}. */
diff --git a/lib/BUCK b/lib/BUCK
index 2e4205f..a7f0ee2 100644
--- a/lib/BUCK
+++ b/lib/BUCK
@@ -41,34 +41,44 @@
 )
 
 maven_jar(
-  name = 'pegdown',
-  id = 'org.pegdown:pegdown:1.4.2',
-  sha1 = 'd96db502ed832df867ff5d918f05b51ba3879ea7',
-  license = 'Apache2.0',
-  deps = [':grappa'],
+  name = 'commonmark',
+  id = 'com.atlassian.commonmark:commonmark:0.5.0',
+  sha1 = '16a2927ba798d41f6d569b22c2639b27cf0288ae',
+  license = 'commonmark',
 )
 
 maven_jar(
-  name = 'grappa',
-  id = 'com.github.parboiled1:grappa:1.0.4',
-  sha1 = 'ad4b44b9c305dad7aa1e680d4b5c8eec9c4fd6f5',
-  license = 'Apache2.0',
+  name = 'cm-autolink',
+  id = 'com.atlassian.commonmark:commonmark-ext-autolink:0.5.0',
+  sha1 = '7bd5e683b32a1ba7849a355472409a0cfaf86176',
+  license = 'commonmark',
   deps = [
-    ':guava',
-    ':jitescript',
-    '//lib/ow2:ow2-asm',
-    '//lib/ow2:ow2-asm-analysis',
-    '//lib/ow2:ow2-asm-tree',
-    '//lib/ow2:ow2-asm-util',
+    ':commonmark',
+    ':autolink',
   ],
 )
 
 maven_jar(
-  name = 'jitescript',
-  id = 'me.qmx.jitescript:jitescript:0.4.0',
-  sha1 = '2e35862b0435c1b027a21f3d6eecbe50e6e08d54',
-  license = 'Apache2.0',
-  visibility = ['//lib:grappa'],
+  name = 'autolink',
+  id = 'org.nibor.autolink:autolink:0.4.0',
+  sha1 = '764f7b0147a0675d971a34282dce9ec76b8307c9',
+  license = 'autolink',
+)
+
+maven_jar(
+  name = 'gfm-strikethrough',
+  id = 'com.atlassian.commonmark:commonmark-ext-gfm-strikethrough:0.5.0',
+  sha1 = 'dd5679fdaae45a9f60250feb075a5b062719625e',
+  license = 'commonmark',
+  deps = [':commonmark'],
+)
+
+maven_jar(
+  name = 'gfm-tables',
+  id = 'com.atlassian.commonmark:commonmark-ext-gfm-tables:0.5.0',
+  sha1 = '082b177b9c886f4cad2c60374783081b04135528',
+  license = 'commonmark',
+  deps = [':commonmark'],
 )
 
 maven_jar(
diff --git a/lib/ow2/BUCK b/lib/ow2/BUCK
deleted file mode 100644
index c4339ce..0000000
--- a/lib/ow2/BUCK
+++ /dev/null
@@ -1,29 +0,0 @@
-VERSION = '5.0.3'
-
-maven_jar(
-  name = 'ow2-asm',
-  id = 'org.ow2.asm:asm:' + VERSION,
-  sha1 = 'dcc2193db20e19e1feca8b1240dbbc4e190824fa',
-  license = 'ow2',
-)
-
-maven_jar(
-  name = 'ow2-asm-analysis',
-  id = 'org.ow2.asm:asm-analysis:' + VERSION,
-  sha1 = 'c7126aded0e8e13fed5f913559a0dd7b770a10f3',
-  license = 'ow2',
-)
-
-maven_jar(
-  name = 'ow2-asm-tree',
-  id = 'org.ow2.asm:asm-tree:' + VERSION,
-  sha1 = '287749b48ba7162fb67c93a026d690b29f410bed',
-  license = 'ow2',
-)
-
-maven_jar(
-  name = 'ow2-asm-util',
-  id = 'org.ow2.asm:asm-util:' + VERSION,
-  sha1 = '1512e5571325854b05fb1efce1db75fcced54389',
-  license = 'ow2',
-)