Markdown: show README.md at bottom of trees

doc.css needs to be adjusted to "cancel out" the styles applied by
go.css and gitiles.css. Within documentation blocks h1/etc. should
have the same styles as when rendered in documentation pages.

Change-Id: I4bd91ad76a801f27abc1e885d8bef18191ef128a
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/PathServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/PathServlet.java
index fa76cb5..6d54991 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/PathServlet.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/PathServlet.java
@@ -36,6 +36,7 @@
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.errors.StopWalkException;
 import org.eclipse.jgit.http.server.ServletUtils;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.FileMode;
 import org.eclipse.jgit.lib.ObjectId;
@@ -417,6 +418,7 @@
   private void showTree(HttpServletRequest req, HttpServletResponse res, WalkResult wr)
       throws IOException {
     GitilesView view = ViewFilter.getView(req);
+    Config cfg = getAccess(req).getConfig();
     List<String> autodive = view.getParameters().get(AUTODIVE_PARAM);
     if (autodive.size() != 1 || !NO_AUTODIVE_VALUE.equals(autodive.get(0))) {
       byte[] path = Constants.encode(view.getPathPart());
@@ -444,7 +446,7 @@
         "title", !view.getPathPart().isEmpty() ? view.getPathPart() : "/",
         "breadcrumbs", view.getBreadcrumbs(wr.hasSingleTree),
         "type", FileType.TREE.toString(),
-        "data", new TreeSoyData(wr.getObjectReader(), view)
+        "data", new TreeSoyData(wr.getObjectReader(), view, cfg, wr.root)
             .setArchiveFormat(getArchiveFormat(getAccess(req)))
             .toSoyData(wr.id, wr.tw)));
   }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/RevisionServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/RevisionServlet.java
index 9ff0fbd..c8336c7 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/RevisionServlet.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/RevisionServlet.java
@@ -32,6 +32,7 @@
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.http.server.ServletUtils;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectLoader;
@@ -40,6 +41,7 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevObject;
 import org.eclipse.jgit.revwalk.RevTag;
+import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -76,6 +78,7 @@
     GitilesView view = ViewFilter.getView(req);
     Repository repo = ServletUtils.getRepository(req);
     GitilesAccess access = getAccess(req);
+    Config cfg = getAccess(req).getConfig();
 
     RevWalk walk = new RevWalk(repo);
     try {
@@ -83,6 +86,7 @@
       List<RevObject> objects = listObjects(walk, view.getRevision());
       List<Map<String, ?>> soyObjects = Lists.newArrayListWithCapacity(objects.size());
       boolean hasBlob = false;
+      boolean hasReadme = false;
 
       // TODO(sop): Allow caching commits by SHA-1 when no S cookie is sent.
       for (RevObject obj : objects) {
@@ -98,9 +102,13 @@
                       .toSoyData(req, (RevCommit) obj, COMMIT_SOY_FIELDS, df)));
               break;
             case OBJ_TREE:
+              Map<String, Object> tree =
+                  new TreeSoyData(walk.getObjectReader(), view, cfg, (RevTree) obj)
+                      .toSoyData(obj);
               soyObjects.add(ImmutableMap.of(
                   "type", Constants.TYPE_TREE,
-                  "data", new TreeSoyData(walk.getObjectReader(), view).toSoyData(obj)));
+                  "data", tree));
+              hasReadme = tree.containsKey("readmeHtml");
               break;
             case OBJ_BLOB:
               soyObjects.add(ImmutableMap.of(
@@ -132,7 +140,8 @@
       renderHtml(req, res, "gitiles.revisionDetail", ImmutableMap.of(
           "title", view.getRevision().getName(),
           "objects", soyObjects,
-          "hasBlob", hasBlob));
+          "hasBlob", hasBlob,
+          "hasReadme", hasReadme));
     } finally {
       walk.release();
     }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/TreeSoyData.java b/gitiles-servlet/src/main/java/com/google/gitiles/TreeSoyData.java
index 70dfcde..0b275e0 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/TreeSoyData.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/TreeSoyData.java
@@ -22,11 +22,23 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.gitiles.PathServlet.FileType;
+import com.google.gitiles.doc.GitilesMarkdown;
+import com.google.gitiles.doc.ImageLoader;
+import com.google.gitiles.doc.MarkdownToHtml;
+import com.google.template.soy.data.SanitizedContent;
 
+import org.eclipse.jgit.errors.LargeObjectException;
 import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.util.RawParseUtils;
+import org.pegdown.ast.RootNode;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
 import java.util.List;
@@ -34,6 +46,8 @@
 
 /** Soy data converter for git trees. */
 public class TreeSoyData {
+  private static final Logger log = LoggerFactory.getLogger(TreeSoyData.class);
+
   /**
    * Number of characters to display for a symlink target. Targets longer than
    * this are abbreviated for display in a tree listing.
@@ -71,11 +85,16 @@
 
   private final ObjectReader reader;
   private final GitilesView view;
+  private final Config cfg;
+  private final RevTree rootTree;
   private ArchiveFormat archiveFormat;
 
-  public TreeSoyData(ObjectReader reader, GitilesView view) {
+  public TreeSoyData(ObjectReader reader, GitilesView view, Config cfg,
+      RevTree rootTree) {
     this.reader = reader;
     this.view = view;
+    this.cfg = cfg;
+    this.rootTree = rootTree;
   }
 
   public TreeSoyData setArchiveFormat(ArchiveFormat archiveFormat) {
@@ -85,6 +104,9 @@
 
   public Map<String, Object> toSoyData(ObjectId treeId, TreeWalk tw) throws MissingObjectException,
          IOException {
+    String readmePath = null;
+    ObjectId readmeId = null;
+
     List<Object> entries = Lists.newArrayList();
     GitilesView.Builder urlBuilder = GitilesView.path().copyFrom(view);
     while (tw.next()) {
@@ -122,6 +144,9 @@
         if (targetUrl != null) {
           entry.put("targetUrl", targetUrl);
         }
+      } else if (isReadmeFile(name) && type == FileType.REGULAR_FILE) {
+        readmePath = tw.getPathString();
+        readmeId = tw.getObjectId(0);
       }
       entries.add(entry);
     }
@@ -141,13 +166,49 @@
       data.put("archiveType", archiveFormat.getShortName());
     }
 
+    if (readmeId != null && cfg.getBoolean("markdown", "render", true)) {
+      data.put("readmePath", readmePath);
+      data.put("readmeHtml", render(readmePath, readmeId));
+    }
+
     return data;
   }
 
+  /** True if the file is the default markdown file to render in tree view. */
+  private static boolean isReadmeFile(String name) {
+    return name.equalsIgnoreCase("README.md");
+  }
+
   public Map<String, Object> toSoyData(ObjectId treeId) throws MissingObjectException, IOException {
     TreeWalk tw = new TreeWalk(reader);
     tw.addTree(treeId);
     tw.setRecursive(false);
     return toSoyData(treeId, tw);
   }
+
+  private SanitizedContent render(String path, ObjectId id) {
+    try {
+      int inputLimit = cfg.getInt("markdown", "inputLimit", 5 << 20);
+      byte[] raw = reader.open(id, Constants.OBJ_BLOB).getCachedBytes(inputLimit);
+      String md = RawParseUtils.decode(raw);
+      RootNode root = GitilesMarkdown.parseFile(view, path, md);
+      if (root == null) {
+        return null;
+      }
+
+      int imageLimit = cfg.getInt("markdown", "imageLimit", 256 << 10);
+      ImageLoader img = null;
+      if (imageLimit > 0) {
+        img = new ImageLoader(reader, view, rootTree, path, imageLimit);
+      }
+
+      return new MarkdownToHtml(view, cfg)
+        .setImageLoader(img)
+        .toSoyHtml(root);
+    } catch (LargeObjectException | IOException e) {
+      log.error(String.format("error rendering %s/%s/%s",
+          view.getRepositoryName(), view.getPathPart(), path), e);
+      return null;
+    }
+  }
 }
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 9557c54..f077688 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
@@ -32,7 +32,7 @@
 import java.util.List;
 
 /** Parses Gitiles extensions to markdown. */
-class GitilesMarkdown extends Parser implements BlockPluginParser {
+public class GitilesMarkdown extends Parser implements BlockPluginParser {
   private static final Logger log = LoggerFactory.getLogger(MarkdownUtil.class);
 
   // SUPPRESS_ALL_HTML is enabled to permit hosting arbitrary user content
@@ -43,7 +43,7 @@
   // this impacting the rendered formatting.
   private static final int MD_OPTIONS = (ALL | SUPPRESS_ALL_HTML) & ~(HARDWRAPS);
 
-  static RootNode parseFile(GitilesView view, String path, String md) {
+  public static RootNode parseFile(GitilesView view, String path, String md) {
     if (md == null) {
       return null;
     }
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/doc.css b/gitiles-servlet/src/main/resources/com/google/gitiles/static/doc.css
index 9272763..24b9b79 100644
--- a/gitiles-servlet/src/main/resources/com/google/gitiles/static/doc.css
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/doc.css
@@ -127,17 +127,42 @@
 .doc {
   color: #444;
   font-size: 13px;
+  line-height: normal;
 }
 
 .doc h1, .doc h2, .doc h3, .doc h4, .doc h5, .doc h6 {
   font-family: "open sans",arial,sans-serif;
+  font-weight: bold;
+  color: #444;
+  height: auto;
+  white-space: normal;
+  overflow: visible;
+  margin: 0.67em 0 0.67em 0;
 }
-.doc h1, .doc h2, .doc h3, .doc h4 { font-weight: bold; }
-.doc h5, .doc h6 { font-weight: normal; }
-.doc h1 { font-size: 20px; }
-.doc h2 { font-size: 16px; }
-.doc h3 { font-size: 14px; }
-.doc h4, .doc h5, .doc h6 { font-size: 13px; }
+.doc h1 {
+  font-size: 20px;
+  margin: 0.67em 0 0.67em 0;
+}
+.doc h2 {
+  font-size: 16px;
+  margin: 0.67em 0 0.67em 0;
+}
+.doc h3 {
+  font-size: 14px;
+  margin: 0.67em 0 0.67em 0;
+}
+.doc h4 {
+  font-size: 13px;
+  margin: 1em 0 1em 0;
+}
+.doc h5 {
+  font-size: 13px;
+  margin: 1.3em 0 1.3em 0;
+}
+.doc h6 {
+  font-size: 13px;
+  margin: 1.6em 0 1.6em 0;
+}
 
 .doc a { text-decoration: none; }
 .doc a:link { color: #245dc1; }
@@ -161,6 +186,15 @@
   border: 0;
 }
 
+.doc em {
+  font-weight: normal;
+  font-style: italic;
+}
+.doc strong {
+  font-weight: bold;
+  color: inherit;
+}
+
 .doc pre {
   border: 1px solid silver;
   background: #fafafa;
@@ -180,6 +214,9 @@
   border-collapse: collapse;
   border-spacing: 0;
 }
+.doc th {
+  text-align: center;
+}
 .doc th, .doc td {
   border: 1px solid #eee;
   padding: 4px 12px;
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/gitiles.css b/gitiles-servlet/src/main/resources/com/google/gitiles/static/gitiles.css
index e2adfc2..13c187f 100644
--- a/gitiles-servlet/src/main/resources/com/google/gitiles/static/gitiles.css
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/gitiles.css
@@ -418,3 +418,18 @@
   font-size: 8pt;
   white-space: pre !important;
 }
+
+/* Styles for README.md in tree view. */
+
+.readme-path {
+  border-top: #ddd solid 1px; /* BORDER */
+  color: #666;
+  font-size: 9pt;
+  padding-top: 5px; /* VPADDING */
+}
+.doc {
+  border-bottom: #ddd solid 1px; /* BORDER */
+}
+.doc h1 {
+  position: static;
+}
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/ObjectDetail.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/ObjectDetail.soy
index d78187f..6c87498 100644
--- a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/ObjectDetail.soy
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/ObjectDetail.soy
@@ -156,6 +156,8 @@
  *     targetName: name of a symlink target, required only if type == 'SYMLINK'.
  *     targetUrl: optional url of a symlink target, required only if
  *         type == 'SYMLINK'.
+ * @param? readmePath optional path of the selected README.md file.
+ * @param? readmeHtml optional rendered README.md contents.
  */
 {template .treeDetail}
 <div class="sha1">
@@ -206,6 +208,11 @@
 {else}
   <p>{msg desc="Informational text for when a tree is empty"}This tree is empty.{/msg}</p>
 {/if}
+
+{if $readmeHtml}
+  <div class="readme-path">{$readmePath}</div>
+  <div class="doc">{$readmeHtml}</div>
+{/if}
 {/template}
 
 /**
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/PathDetail.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/PathDetail.soy
index d2f8b3a..e42ee97 100644
--- a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/PathDetail.soy
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/PathDetail.soy
@@ -31,6 +31,10 @@
   {call .header data="all"}
     {param css: [gitiles.PRETTIFY_CSS_URL] /}
   {/call}
+{elseif $data.readmeHtml}
+  {call .header data="all"}
+    {param css: [gitiles.DOC_CSS_URL] /}
+  {/call}
 {else}
   {call .header data="all" /}
 {/if}
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/RevisionDetail.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/RevisionDetail.soy
index 2bc335d..3b2fcc2 100644
--- a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/RevisionDetail.soy
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/RevisionDetail.soy
@@ -22,6 +22,7 @@
  * @param? headerVariant variant name for custom header.
  * @param breadcrumbs breadcrumbs for this page.
  * @param? hasBlob set to true if the revision or its peeled value is a blob.
+ * @param? hasReadme set to true if the treeDetail has readmeHtml.
  * @param objects list of objects encountered when peeling this object. Each
  *     object has a "type" key with one of the
  *     org.eclipse.jgit.lib.Contants.TYPE_* constant strings, and a "data" key
@@ -33,6 +34,10 @@
   {call .header data="all"}
     {param css: [gitiles.PRETTIFY_CSS_URL] /}
   {/call}
+{elseif $hasReadme}
+  {call .header data="all"}
+    {param css: [gitiles.DOC_CSS_URL] /}
+  {/call}
 {else}
   {call .header data="all" /}
 {/if}