Markdown: allow named anchors as <a name="id"></a> and {#id}

Support author-defined named anchors within the document. Use HTML style
<a name="..."></a> as well as a common markdown extension, {#id}.

Bug: issue 80
Change-Id: I4e89e6eefb268900969618ced1f3b5556cc4a4ef
diff --git a/Documentation/markdown.md b/Documentation/markdown.md
index 7ee70d9..2dfa85f 100644
--- a/Documentation/markdown.md
+++ b/Documentation/markdown.md
@@ -335,6 +335,18 @@
 file will present the rendered markdown, while a link to a source file
 will display the syntax highlighted source.
 
+### Named anchors
+
+Explicit anchors may be inserted into a document to make stable references.
+This is recommended at the end of a section header to avoid ambigous names.
+The following are identical and will wrap the section header inside of an
+anchor tag:
+
+```
+## Examples {#live-examples}
+## Examples <a name="live-examples"></a>
+```
+
 ### Images
 
 Similar to links but begin with `!` to denote an image reference:
@@ -377,8 +389,8 @@
 parser with no warnings, and no output from that section of the
 document.
 
-There is a small exception for the `<iframe>` element, see
-[HTML IFrame](#HTML-IFrame) below.
+There is a small exception for `<a name>` and `<iframe>` elements, see
+[named anchor](#Named-anchors) and [HTML IFrame](#HTML-IFrame).
 
 ## Markdown extensions
 
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 4a7df37..dcef245 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
@@ -29,6 +29,7 @@
 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;
@@ -37,7 +38,8 @@
 import java.util.List;
 
 /** Parses Gitiles extensions to markdown. */
-public class GitilesMarkdown extends Parser implements BlockPluginParser {
+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
@@ -93,6 +95,14 @@
     };
   }
 
+  @Override
+  public Rule[] inlinePluginRules() {
+    return new Rule[]{
+        namedAnchorHtmlStyle(),
+        namedAnchorMarkdownExtensionStyle(),
+    };
+  }
+
   public Rule toc() {
     return NodeSequence(
         string("[TOC]"),
@@ -107,6 +117,28 @@
         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();
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 89a2b36..d189c2d 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
@@ -77,6 +77,7 @@
   private ImageLoader imageLoader;
   private boolean readme;
   private TableState table;
+  private boolean outputNamedAnchor = true;
 
   public MarkdownToHtml(GitilesView view, Config cfg) {
     this.view = view;
@@ -170,14 +171,26 @@
 
   @Override
   public void visit(HeaderNode node) {
-    String tag = "h" + node.getLevel();
-    html.open(tag);
     String id = toc.idFromHeader(node);
     if (id != null) {
-      html.attribute("id", id);
+      html.open("a").attribute("name", id);
     }
-    visitChildren(node);
-    html.close(tag);
+    try {
+      outputNamedAnchor = false;
+      wrapChildren("h" + node.getLevel(), node);
+    } finally {
+      outputNamedAnchor = true;
+    }
+    if (id != null) {
+      html.close("a");
+    }
+  }
+
+  @Override
+  public void visit(NamedAnchorNode node) {
+    if (outputNamedAnchor) {
+      html.open("a").attribute("name", node.name).close("a");
+    }
   }
 
   @Override
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
new file mode 100644
index 0000000..d5c4f8c
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/NamedAnchorNode.java
@@ -0,0 +1,39 @@
+// 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/TocFormatter.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/TocFormatter.java
index 3ca9d5b..ed5315c 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
@@ -134,15 +134,36 @@
       stack.removeLast();
     }
 
+    NamedAnchorNode node = findAnchor(header);
+    if (node != null) {
+      entries.put(node.name, new TocEntry(stack, header, false, node.name));
+      stack.add(header);
+      outline.add(header);
+      return;
+    }
+
     String title = MarkdownUtil.getInnerText(header);
     if (title != null) {
       String id = idFromTitle(title);
-      entries.put(id, new TocEntry(stack, header, id));
+      entries.put(id, new TocEntry(stack, header, true, id));
       stack.add(header);
       outline.add(header);
     }
   }
 
+  private static NamedAnchorNode findAnchor(Node node) {
+    for (Node child : node.getChildren()) {
+      if (child instanceof NamedAnchorNode) {
+        return (NamedAnchorNode) child;
+      }
+      NamedAnchorNode anchor = findAnchor(child);
+      if (anchor != null) {
+        return anchor;
+      }
+    }
+    return null;
+  }
+
   private Map<HeaderNode, String> generateIds(Multimap<String, TocEntry> entries) {
     Multimap<String, TocEntry> tmp = ArrayListMultimap.create(entries.size(), 2);
     for (Collection<TocEntry> headers : entries.asMap().values()) {
@@ -154,6 +175,11 @@
 
       // Try to deduplicate by prefixing with text derived from parents.
       for (TocEntry entry : headers) {
+        if (!entry.generated) {
+          tmp.put(entry.id, entry);
+          continue;
+        }
+
         StringBuilder b = new StringBuilder();
         for (HeaderNode p : entry.path) {
           if (p.getLevel() > 1 || countH1 > 1) {
@@ -187,11 +213,13 @@
   private static class TocEntry {
     final HeaderNode[] path;
     final HeaderNode header;
+    final boolean generated;
     String id;
 
-    TocEntry(Deque<HeaderNode> stack, HeaderNode header, String id) {
+    TocEntry(Deque<HeaderNode> stack, HeaderNode header, boolean generated, String id) {
       this.path = stack.toArray(new HeaderNode[stack.size()]);
       this.header = header;
+      this.generated = generated;
       this.id = id;
     }
   }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/Visitor.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/Visitor.java
index d8f269c..5562be2 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/Visitor.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/Visitor.java
@@ -20,4 +20,5 @@
   void visit(DivNode node);
   void visit(IframeNode node);
   void visit(TocNode node);
+  void visit(NamedAnchorNode node);
 }
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 f5dc889..85e62c5 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
@@ -112,6 +112,8 @@
       // allow
     } else if ("title".equals(att) && ("img".equals(tag) || "a".equals(tag))) {
       // allow
+    } else if ("name".equals(att) && "a".equals(tag)) {
+      // allow
     } else if (("colspan".equals(att) || "align".equals(att))
         && ("td".equals(tag) || "th".equals(tag))) {
       // allow
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/doc/DocServletTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/doc/DocServletTest.java
index 7ad15b5..dffe4f2 100644
--- a/gitiles-servlet/src/test/java/com/google/gitiles/doc/DocServletTest.java
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/doc/DocServletTest.java
@@ -69,7 +69,7 @@
 
     String html = buildHtml("/repo/+doc/master/README.md");
     assertTrue(html.contains("<title>" + title + "</title>"));
-    assertTrue(html.contains("<h1 id=\"DocServletTest-simpleDoc\">" + title + "</h1>"));
+    assertTrue(html.contains("<h1>" + title + "</h1>"));
     assertTrue(html.contains("<a href=\"" + url + "\">Markdown</a>"));
   }
 
@@ -91,7 +91,7 @@
     assertTrue(html.contains("<h2>page</h2>"));
     assertTrue(html.contains("<li><a href=\"index.md\">Home</a></li>"));
     assertTrue(html.contains("<li><a href=\"README.md\">README</a></li>"));
-    assertTrue(html.contains("<h1 id=\"page\">page</h1>"));
+    assertTrue(html.contains("<h1>page</h1>"));
   }
 
   @Test
@@ -106,7 +106,7 @@
         .create();
 
     String html = buildHtml("/repo/+doc/master/");
-    assertTrue(html.contains("<h1 id=\"B_Ad\">B. Ad</h1>"));
+    assertTrue(html.contains("<h1>B. Ad</h1>"));
     assertTrue(html.contains("Non-HTML is fine."));
 
     assertFalse(html.contains("window.alert"));
@@ -114,6 +114,18 @@
   }
 
   @Test
+  public void namedAnchor() throws Exception {
+    String markdown = "# Section {#debug}\n"
+        + "# Other <a name=\"old-school\"></a>\n";
+    repo.branch("master").commit()
+      .add("index.md", markdown)
+      .create();
+    String html = buildHtml("/repo/+doc/master/");
+    assertTrue(html.contains("<a name=\"debug\"><h1>Section</h1></a>"));
+    assertTrue(html.contains("<a name=\"old-school\"><h1>Other</h1></a>"));
+  }
+
+  @Test
   public void incompleteHtmlIsLiteral() throws Exception {
     String markdown = "Incomplete <html is literal.";
     repo.branch("master").commit()