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()