Merge "Improve styling/branding options"
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/MimeTypes.java b/gitiles-servlet/src/main/java/com/google/gitiles/MimeTypes.java
new file mode 100644
index 0000000..ed219ce
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/MimeTypes.java
@@ -0,0 +1,56 @@
+// 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;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableMap;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Map;
+import java.util.Properties;
+
+public class MimeTypes {
+  public static final String ANY = "application/octet-stream";
+  private static final ImmutableMap<String, String> TYPES;
+
+  static {
+    Properties p = new Properties();
+    try (InputStream in = MimeTypes.class.getResourceAsStream("mime-types.properties")) {
+      p.load(in);
+    } catch (IOException e) {
+      throw new RuntimeException("Cannot load mime-types.properties", e);
+    }
+
+    ImmutableMap.Builder<String, String> m = ImmutableMap.builder();
+    for (Map.Entry<Object, Object> e : p.entrySet()) {
+      m.put(((String) e.getKey()).toLowerCase(), (String) e.getValue());
+    }
+    TYPES = m.build();
+  }
+
+  public static String getMimeType(String path) {
+    int d = path.lastIndexOf('.');
+    if (d == -1) {
+      return ANY;
+    }
+
+    String ext = path.substring(d + 1);
+    String type = TYPES.get(ext.toLowerCase());
+    return MoreObjects.firstNonNull(type, ANY);
+  }
+
+  private MimeTypes() {}
+}
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 da9138e..30470f8 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/PathServlet.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/PathServlet.java
@@ -495,7 +495,7 @@
             "breadcrumbs", view.getBreadcrumbs(wr.hasSingleTree),
             "type", FileType.TREE.toString(),
             "data",
-                new TreeSoyData(wr.getObjectReader(), view, cfg, wr.root)
+                new TreeSoyData(wr.getObjectReader(), view, cfg, wr.root, req.getRequestURI())
                     .setArchiveFormat(getArchiveFormat(getAccess(req)))
                     .toSoyData(wr.id, wr.tw)));
   }
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 9a05f35..3dcf1ea 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/ReadmeHelper.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/ReadmeHelper.java
@@ -13,24 +13,20 @@
 // limitations under the License.
 
 package com.google.gitiles;
-
 import com.google.gitiles.doc.GitilesMarkdown;
-import com.google.gitiles.doc.ImageLoader;
+import com.google.gitiles.doc.MarkdownConfig;
 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.MissingObjectException;
-import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.FileMode;
 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.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -41,25 +37,30 @@
 
   private final ObjectReader reader;
   private final GitilesView view;
-  private final Config cfg;
+  private final MarkdownConfig config;
   private final RevTree rootTree;
-  private final boolean render;
+  private final String requestUri;
 
   private String readmePath;
   private ObjectId readmeId;
 
-  ReadmeHelper(ObjectReader reader, GitilesView view, Config cfg, RevTree rootTree) {
+  ReadmeHelper(
+      ObjectReader reader,
+      GitilesView view,
+      MarkdownConfig config,
+      RevTree rootTree,
+      String requestUri) {
     this.reader = reader;
     this.view = view;
-    this.cfg = cfg;
+    this.config = config;
     this.rootTree = rootTree;
-    render = cfg.getBoolean("markdown", "render", true);
+    this.requestUri = requestUri;
   }
 
   void scanTree(RevTree tree)
       throws MissingObjectException, IncorrectObjectTypeException, CorruptObjectException,
           IOException {
-    if (render) {
+    if (config.render) {
       TreeWalk tw = new TreeWalk(reader);
       tw.setRecursive(false);
       tw.addTree(tree);
@@ -70,7 +71,7 @@
   }
 
   void considerEntry(TreeWalk tw) {
-    if (render
+    if (config.render
         && FileMode.REGULAR_FILE.equals(tw.getRawMode(0))
         && isReadmeFile(tw.getNameString())) {
       readmePath = tw.getPathString();
@@ -88,21 +89,16 @@
 
   SanitizedContent render() {
     try {
-      int inputLimit = cfg.getInt("markdown", "inputLimit", 5 << 20);
-      byte[] raw = reader.open(readmeId, Constants.OBJ_BLOB).getCachedBytes(inputLimit);
-      String md = RawParseUtils.decode(raw);
-      Node root = GitilesMarkdown.parse(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, readmePath, imageLimit);
-      }
-
-      return new MarkdownToHtml(view, cfg, readmePath).setImageLoader(img).toSoyHtml(root);
+      byte[] raw = reader.open(readmeId, Constants.OBJ_BLOB).getCachedBytes(config.inputLimit);
+      return MarkdownToHtml.builder()
+          .setConfig(config)
+          .setGitilesView(view)
+          .setRequestUri(requestUri)
+          .setFilePath(readmePath)
+          .setReader(reader)
+          .setRootTree(rootTree)
+          .build()
+          .toSoyHtml(GitilesMarkdown.parse(raw));
     } catch (RuntimeException | IOException err) {
       log.error(
           String.format(
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryIndexServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryIndexServlet.java
index f4d9327..fee1507 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryIndexServlet.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryIndexServlet.java
@@ -23,6 +23,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Maps;
 import com.google.gitiles.DateFormatter.Format;
+import com.google.gitiles.doc.MarkdownConfig;
 import com.google.gson.reflect.TypeToken;
 import com.google.template.soy.data.SanitizedContent;
 
@@ -98,7 +99,7 @@
       if (headId != null) {
         RevObject head = walk.parseAny(headId);
         int limit = LOG_LIMIT;
-        Map<String, Object> readme = renderReadme(walk, view, access.getConfig(), head);
+        Map<String, Object> readme = renderReadme(req, walk, view, access.getConfig(), head);
         if (readme != null) {
           data.putAll(readme);
           limit = LOG_WITH_README_LIMIT;
@@ -156,7 +157,8 @@
   }
 
   private static Map<String, Object> renderReadme(
-      RevWalk walk, GitilesView view, Config cfg, RevObject head) throws IOException {
+      HttpServletRequest req, RevWalk walk, GitilesView view, Config cfg, RevObject head)
+      throws IOException {
     RevTree rootTree;
     try {
       rootTree = walk.parseTree(head);
@@ -168,8 +170,9 @@
         new ReadmeHelper(
             walk.getObjectReader(),
             GitilesView.path().copyFrom(view).setRevision(Revision.HEAD).setPathPart("/").build(),
-            cfg,
-            rootTree);
+            MarkdownConfig.get(cfg),
+            rootTree,
+            req.getRequestURI());
     readme.scanTree(rootTree);
     if (readme.isPresent()) {
       SanitizedContent html = readme.render();
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 9c7fdb0..cc80490 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/RevisionServlet.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/RevisionServlet.java
@@ -105,7 +105,9 @@
               break;
             case OBJ_TREE:
               Map<String, Object> tree =
-                  new TreeSoyData(walk.getObjectReader(), view, cfg, (RevTree) obj).toSoyData(obj);
+                  new TreeSoyData(
+                          walk.getObjectReader(), view, cfg, (RevTree) obj, req.getRequestURI())
+                      .toSoyData(obj);
               soyObjects.add(ImmutableMap.of("type", Constants.TYPE_TREE, "data", tree));
               hasReadme = tree.containsKey("readmeHtml");
               break;
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 f1366ad..43fddfb 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/TreeSoyData.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/TreeSoyData.java
@@ -22,6 +22,7 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.gitiles.PathServlet.FileType;
+import com.google.gitiles.doc.MarkdownConfig;
 
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.Config;
@@ -72,13 +73,16 @@
   private final GitilesView view;
   private final Config cfg;
   private final RevTree rootTree;
+  private final String requestUri;
   private ArchiveFormat archiveFormat;
 
-  public TreeSoyData(ObjectReader reader, GitilesView view, Config cfg, RevTree rootTree) {
+  public TreeSoyData(
+      ObjectReader reader, GitilesView view, Config cfg, RevTree rootTree, String requestUri) {
     this.reader = reader;
     this.view = view;
     this.cfg = cfg;
     this.rootTree = rootTree;
+    this.requestUri = requestUri;
   }
 
   public TreeSoyData setArchiveFormat(ArchiveFormat archiveFormat) {
@@ -88,7 +92,8 @@
 
   public Map<String, Object> toSoyData(ObjectId treeId, TreeWalk tw)
       throws MissingObjectException, IOException {
-    ReadmeHelper readme = new ReadmeHelper(reader, view, cfg, rootTree);
+    ReadmeHelper readme =
+        new ReadmeHelper(reader, view, MarkdownConfig.get(cfg), rootTree, requestUri);
     List<Object> entries = Lists.newArrayList();
     GitilesView.Builder urlBuilder = GitilesView.path().copyFrom(view);
     while (tw.next()) {
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 6561ada..822f2c4 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
@@ -37,26 +37,28 @@
 
 import org.commonmark.node.Node;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.LargeObjectException;
 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;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.treewalk.TreeWalk;
-import org.eclipse.jgit.util.RawParseUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
 import java.util.HashMap;
 import java.util.Map;
 
+import javax.annotation.Nullable;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
 public class DocServlet extends BaseServlet {
+  private static final Logger log = LoggerFactory.getLogger(DocServlet.class);
   private static final long serialVersionUID = 1L;
 
   private static final String INDEX_MD = "index.md";
@@ -75,8 +77,8 @@
 
   @Override
   protected void doGetHtml(HttpServletRequest req, HttpServletResponse res) throws IOException {
-    Config cfg = getAccess(req).getConfig();
-    if (!cfg.getBoolean("markdown", "render", true)) {
+    MarkdownConfig cfg = MarkdownConfig.get(getAccess(req).getConfig());
+    if (!cfg.render) {
       res.setStatus(SC_NOT_FOUND);
       return;
     }
@@ -84,6 +86,7 @@
     GitilesView view = ViewFilter.getView(req);
     Repository repo = ServletUtils.getRepository(req);
     try (RevWalk rw = new RevWalk(repo)) {
+      ObjectReader reader = rw.getObjectReader();
       String path = view.getPathPart();
       RevTree root;
       try {
@@ -93,53 +96,51 @@
         return;
       }
 
-      SourceFile srcmd = findFile(rw, root, path);
+      MarkdownFile srcmd = findFile(rw, root, path);
       if (srcmd == null) {
         res.setStatus(SC_NOT_FOUND);
         return;
       }
 
-      SourceFile navmd = findFile(rw, root, NAVBAR_MD);
-      String reqEtag = req.getHeader(HttpHeaders.IF_NONE_MATCH);
+      MarkdownFile navmd = findFile(rw, root, NAVBAR_MD);
       String curEtag = etag(srcmd, navmd);
-      if (reqEtag != null && reqEtag.equals(curEtag)) {
+      if (etagMatch(req, curEtag)) {
         res.setStatus(SC_NOT_MODIFIED);
         return;
       }
 
       view = view.toBuilder().setPathPart(srcmd.path).build();
-      int inputLimit = cfg.getInt("markdown", "inputLimit", 5 << 20);
-      Node doc = GitilesMarkdown.parse(srcmd.read(rw.getObjectReader(), inputLimit));
-      if (doc == null) {
-        res.sendRedirect(GitilesView.show().copyFrom(view).toUrl());
+      try {
+        srcmd.read(reader, cfg);
+        if (navmd != null) {
+          navmd.read(reader, cfg);
+        }
+      } catch (LargeObjectException.ExceedsLimit errBig) {
+        fileTooBig(res, view, errBig);
+        return;
+      } catch (IOException err) {
+        readError(res, view, err);
         return;
       }
 
-      String navPath = null;
-      String navMarkdown = null;
-      Node nav = null;
-      if (navmd != null) {
-        navPath = navmd.path;
-        navMarkdown = navmd.read(rw.getObjectReader(), inputLimit);
-        nav = GitilesMarkdown.parse(navMarkdown);
-        if (nav == null) {
-          res.setStatus(SC_INTERNAL_SERVER_ERROR);
-          return;
-        }
-      }
-
-      int imageLimit = cfg.getInt("markdown", "imageLimit", 256 << 10);
-      ImageLoader img = null;
-      if (imageLimit > 0) {
-        img = new ImageLoader(rw.getObjectReader(), view, root, srcmd.path, imageLimit);
-      }
-
+      MarkdownToHtml.Builder fmt =
+          MarkdownToHtml.builder()
+              .setConfig(cfg)
+              .setGitilesView(view)
+              .setRequestUri(req.getRequestURI())
+              .setReader(reader)
+              .setRootTree(root);
       res.setHeader(HttpHeaders.ETAG, curEtag);
-      showDoc(req, res, view, cfg, img, navPath, navMarkdown, nav, srcmd.path, doc);
+      showDoc(req, res, view, cfg, fmt, navmd, srcmd);
     }
   }
 
-  private String etag(SourceFile srcmd, SourceFile navmd) {
+  private static boolean etagMatch(HttpServletRequest req, String etag) {
+    String reqEtag = req.getHeader(HttpHeaders.IF_NONE_MATCH);
+    return reqEtag != null && reqEtag.equals(etag);
+  }
+
+  private String etag(MarkdownFile srcmd, @Nullable MarkdownFile navmd) {
     byte[] b = new byte[Constants.OBJECT_ID_LENGTH];
     Hasher h = Hashing.sha1().newHasher();
     h.putInt(ETAG_GEN);
@@ -169,32 +170,30 @@
       HttpServletRequest req,
       HttpServletResponse res,
       GitilesView view,
-      Config cfg,
-      ImageLoader img,
-      String navPath,
-      String navMarkdown,
-      Node nav,
-      String docPath,
-      Node doc)
+      MarkdownConfig cfg,
+      MarkdownToHtml.Builder fmt,
+      MarkdownFile navFile,
+      MarkdownFile srcFile)
       throws IOException {
     Map<String, Object> data = new HashMap<>();
+    Navbar navbar = new Navbar();
+    if (navFile != null) {
+      navbar.setFormatter(fmt.setFilePath(navFile.path).build());
+      navbar.setMarkdown(navFile.content);
+    }
+    data.putAll(navbar.toSoyData());
 
-    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()));
+    Node doc = GitilesMarkdown.parse(srcFile.content);
+    data.put("pageTitle", pageTitle(doc, srcFile));
     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("bodyHtml", new MarkdownToHtml(view, cfg, docPath).setImageLoader(img).toSoyHtml(doc));
-
-    String analyticsId = cfg.getString("google", null, "analyticsId");
-    if (!Strings.isNullOrEmpty(analyticsId)) {
-      data.put("analyticsId", analyticsId);
+    if (cfg.analyticsId != null) {
+      data.put("analyticsId", cfg.analyticsId);
     }
+    data.put("bodyHtml", fmt.setFilePath(srcFile.path).build().toSoyHtml(doc));
 
     String page = renderer.render(SOY_TEMPLATE, data);
     byte[] raw = page.getBytes(UTF_8);
@@ -211,7 +210,13 @@
     res.getOutputStream().write(raw);
   }
 
-  private static SourceFile findFile(RevWalk rw, RevTree root, String path) throws IOException {
+  private static String pageTitle(Node doc, MarkdownFile srcFile) {
+    String title = MarkdownUtil.getTitle(doc);
+    return MoreObjects.firstNonNull(title, srcFile.path);
+  }
+
+  @Nullable
+  private static MarkdownFile findFile(RevWalk rw, RevTree root, String path) throws IOException {
     if (Strings.isNullOrEmpty(path)) {
       path = INDEX_MD;
     }
@@ -232,7 +237,7 @@
       if (!path.endsWith(".md")) {
         return null;
       }
-      return new SourceFile(path, tw.getObjectId(0));
+      return new MarkdownFile(path, tw.getObjectId(0));
     }
     return null;
   }
@@ -247,19 +252,48 @@
     return false;
   }
 
-  private static class SourceFile {
+  private static void fileTooBig(
+      HttpServletResponse res, GitilesView view, LargeObjectException.ExceedsLimit errBig)
+      throws IOException {
+    if (view.getType() == GitilesView.Type.ROOTED_DOC) {
+      log.error(
+          String.format(
+              "markdown too large: %s/%s %s %s: %s",
+              view.getHostName(),
+              view.getRepositoryName(),
+              view.getRevision(),
+              view.getPathPart(),
+              errBig.getMessage()));
+      res.setStatus(SC_INTERNAL_SERVER_ERROR);
+    } else {
+      res.sendRedirect(GitilesView.show().copyFrom(view).toUrl());
+    }
+  }
+
+  private static void readError(HttpServletResponse res, GitilesView view, IOException err) {
+    log.error(
+        String.format(
+            "cannot load markdown %s/%s %s %s",
+            view.getHostName(),
+            view.getRepositoryName(),
+            view.getRevision(),
+            view.getPathPart()),
+        err);
+    res.setStatus(SC_INTERNAL_SERVER_ERROR);
+  }
+
+  private static class MarkdownFile {
     final String path;
     final ObjectId id;
+    byte[] content;
 
-    SourceFile(String path, ObjectId id) {
+    MarkdownFile(String path, ObjectId id) {
       this.path = path;
       this.id = id;
     }
 
-    String read(ObjectReader reader, int inputLimit) throws IOException {
-      ObjectLoader obj = reader.open(id, OBJ_BLOB);
-      byte[] raw = obj.getCachedBytes(inputLimit);
-      return RawParseUtils.decode(raw);
+    void read(ObjectReader reader, MarkdownConfig cfg) throws IOException {
+      content = reader.open(id, OBJ_BLOB).getCachedBytes(cfg.inputLimit);
     }
   }
 }
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 541343b..fcd00dc 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
@@ -21,6 +21,7 @@
 import org.commonmark.ext.gfm.tables.TablesExtension;
 import org.commonmark.node.Node;
 import org.commonmark.parser.Parser;
+import org.eclipse.jgit.util.RawParseUtils;
 
 /** Parses Gitiles style CommonMark Markdown. */
 public class GitilesMarkdown {
@@ -40,8 +41,12 @@
                   TocExtension.create()))
           .build();
 
+  public static Node parse(byte[] md) {
+    return parse(RawParseUtils.decode(md));
+  }
+
   public static Node parse(String md) {
-    return md != null ? PARSER.parse(md) : null;
+    return PARSER.parse(md);
   }
 
   private GitilesMarkdown() {}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/ImageLoader.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/ImageLoader.java
index b381616..4fca76a 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/ImageLoader.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/ImageLoader.java
@@ -14,9 +14,10 @@
 
 package com.google.gitiles.doc;
 
-import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.io.BaseEncoding;
 import com.google.gitiles.GitilesView;
+import com.google.gitiles.MimeTypes;
 import com.google.template.soy.shared.restricted.EscapingConventions.FilterImageDataUri;
 
 import org.eclipse.jgit.errors.LargeObjectException;
@@ -31,93 +32,68 @@
 
 import java.io.IOException;
 
+import javax.annotation.Nullable;
+
 /** Reads an image from Git and converts to {@code data:image/*;base64,...} */
-public class ImageLoader {
+class ImageLoader {
   private static final Logger log = LoggerFactory.getLogger(ImageLoader.class);
+  private static final ImmutableSet<String> ALLOWED_TYPES =
+      ImmutableSet.of("image/gif", "image/jpeg", "image/png");
 
   private final ObjectReader reader;
   private final GitilesView view;
+  private final MarkdownConfig config;
   private final RevTree root;
-  private final String path;
-  private final int imageLimit;
 
-  public ImageLoader(
-      ObjectReader reader, GitilesView view, RevTree root, String path, int maxImageSize) {
+  ImageLoader(ObjectReader reader, GitilesView view, MarkdownConfig config, RevTree root) {
     this.reader = reader;
     this.view = view;
+    this.config = config;
     this.root = root;
-    this.path = path;
-    this.imageLimit = maxImageSize;
   }
 
-  String loadImage(String src) {
-    if (src.startsWith("/")) {
-      return readAndBase64Encode(src.substring(1));
+  String inline(@Nullable String markdownPath, String imagePath) {
+    String data = inlineMaybe(markdownPath, imagePath);
+    if (data != null) {
+      return data;
     }
-
-    String base = directory();
-    while (src.startsWith("../")) {
-      int s = base.lastIndexOf('/');
-      if (s == -1) {
-        return FilterImageDataUri.INSTANCE.getInnocuousOutput();
-      }
-      base = base.substring(0, s + 1);
-      src = src.substring("../".length());
-    }
-    return readAndBase64Encode(base + src);
+    return FilterImageDataUri.INSTANCE.getInnocuousOutput();
   }
 
-  private String directory() {
-    int s = path.lastIndexOf('/');
-    if (s > 0) {
-      return path.substring(0, s + 1);
+  private String inlineMaybe(@Nullable String markdownPath, String imagePath) {
+    if (config.imageLimit <= 0) {
+      return null;
     }
-    return "";
-  }
 
-  private String readAndBase64Encode(String path) {
-    String type = getMimeType(path);
-    if (type == null) {
-      return FilterImageDataUri.INSTANCE.getInnocuousOutput();
+    String path = PathResolver.resolve(markdownPath, imagePath);
+    if (path == null) {
+      return null;
+    }
+
+    String type = MimeTypes.getMimeType(path);
+    if (!ALLOWED_TYPES.contains(type)) {
+      return null;
     }
 
     try {
       TreeWalk tw = TreeWalk.forPath(reader, path, root);
       if (tw == null || tw.getFileMode(0) != FileMode.REGULAR_FILE) {
-        return FilterImageDataUri.INSTANCE.getInnocuousOutput();
+        return null;
       }
 
       ObjectId id = tw.getObjectId(0);
-      byte[] raw = reader.open(id, Constants.OBJ_BLOB).getCachedBytes(imageLimit);
-      if (raw.length > imageLimit) {
-        return FilterImageDataUri.INSTANCE.getInnocuousOutput();
+      byte[] raw = reader.open(id, Constants.OBJ_BLOB).getCachedBytes(config.imageLimit);
+      if (raw.length > config.imageLimit) {
+        return null;
       }
-
       return "data:" + type + ";base64," + BaseEncoding.base64().encode(raw);
     } catch (LargeObjectException.ExceedsLimit e) {
-      return FilterImageDataUri.INSTANCE.getInnocuousOutput();
-    } catch (IOException e) {
+      return null;
+    } catch (IOException err) {
+      String repo = view != null ? view.getRepositoryName() : "<unknown>";
       log.error(
-          String.format(
-              "cannot read repo %s image %s from %s", view.getRepositoryName(), path, root.name()),
-          e);
-      return FilterImageDataUri.INSTANCE.getInnocuousOutput();
-    }
-  }
-
-  private static final ImmutableMap<String, String> TYPES =
-      ImmutableMap.of(
-          "png", "image/png",
-          "gif", "image/gif",
-          "jpg", "image/jpeg",
-          "jpeg", "image/jpeg");
-
-  private static String getMimeType(String path) {
-    int d = path.lastIndexOf('.');
-    if (d == -1) {
+          String.format("cannot read repo %s image %s from %s", repo, path, root.name()), err);
       return null;
     }
-    String ext = path.substring(d + 1);
-    return TYPES.get(ext.toLowerCase());
   }
 }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownConfig.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownConfig.java
new file mode 100644
index 0000000..f229f8e
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownConfig.java
@@ -0,0 +1,74 @@
+// 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.Strings;
+import com.google.common.collect.ImmutableList;
+
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Config.SectionParser;
+import org.eclipse.jgit.util.StringUtils;
+
+public class MarkdownConfig {
+  public static final int IMAGE_LIMIT = 256 << 10;
+
+  public static MarkdownConfig get(Config cfg) {
+    return cfg.get(CONFIG_PARSER);
+  }
+
+  private static SectionParser<MarkdownConfig> CONFIG_PARSER =
+      new SectionParser<MarkdownConfig>() {
+        @Override
+        public MarkdownConfig parse(Config cfg) {
+          return new MarkdownConfig(cfg);
+        }
+      };
+
+  public final boolean render;
+  public final int inputLimit;
+
+  final int imageLimit;
+  final String analyticsId;
+
+  private final boolean allowAnyIFrame;
+  private final ImmutableList<String> allowIFrame;
+
+  MarkdownConfig(Config cfg) {
+    render = cfg.getBoolean("markdown", "render", true);
+    inputLimit = cfg.getInt("markdown", "inputLimit", 5 << 20);
+    imageLimit = cfg.getInt("markdown", "imageLimit", IMAGE_LIMIT);
+    analyticsId = Strings.emptyToNull(cfg.getString("google", null, "analyticsId"));
+
+    String[] f = cfg.getStringList("markdown", null, "allowiframe");
+    allowAnyIFrame = f.length == 1 && StringUtils.toBooleanOrNull(f[0]) == Boolean.TRUE;
+    if (allowAnyIFrame) {
+      allowIFrame = ImmutableList.of();
+    } else {
+      allowIFrame = ImmutableList.copyOf(f);
+    }
+  }
+
+  boolean isIFrameAllowed(String src) {
+    if (allowAnyIFrame) {
+      return true;
+    }
+    for (String url : allowIFrame) {
+      if (url.equals(src) || (url.endsWith("/") && src.startsWith(url))) {
+        return true;
+      }
+    }
+    return false;
+  }
+}
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 1b88d5b..7a116c1 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
@@ -22,6 +22,7 @@
 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;
@@ -56,13 +57,15 @@
 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.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.revwalk.RevTree;
 
 import java.util.List;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
+import javax.annotation.Nullable;
+
 import prettify.parser.Prettify;
 import syntaxhighlight.ParseResult;
 
@@ -72,34 +75,77 @@
  * Callers must create a new instance for each document.
  */
 public class MarkdownToHtml implements Visitor {
-  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 boolean outputNamedAnchor = true;
-
-  /**
-   * Initialize a Markdown to HTML converter.
-   *
-   * @param view view used to access this Markdown on the web. Some elements of
-   *        the view may be used to generate hyperlinks to other files, e.g.
-   *        repository name and revision.
-   * @param cfg
-   * @param filePath actual path of the Markdown file in the Git repository. This must
-   *        always be a file, e.g. {@code doc/README.md}. The path is used to
-   *        resolve relative links within the repository.
-   */
-  public MarkdownToHtml(GitilesView view, Config cfg, String filePath) {
-    this.view = view;
-    this.cfg = cfg;
-    this.filePath = filePath;
+  public static Builder builder() {
+    return new Builder();
   }
 
-  public MarkdownToHtml setImageLoader(ImageLoader img) {
-    imageLoader = img;
-    return this;
+  public static class Builder {
+    private String requestUri;
+    private GitilesView view;
+    private MarkdownConfig config;
+    private String filePath;
+    private ObjectReader reader;
+    private RevTree root;
+
+    Builder() {}
+
+    public Builder setRequestUri(@Nullable String uri) {
+      requestUri = uri;
+      return this;
+    }
+
+    public Builder setGitilesView(@Nullable GitilesView view) {
+      this.view = view;
+      return this;
+    }
+
+    public Builder setConfig(@Nullable MarkdownConfig config) {
+      this.config = config;
+      return this;
+    }
+
+    public Builder setFilePath(@Nullable String filePath) {
+      this.filePath = Strings.emptyToNull(filePath);
+      return this;
+    }
+
+    public Builder setReader(ObjectReader reader) {
+      this.reader = reader;
+      return this;
+    }
+
+    public Builder setRootTree(RevTree tree) {
+      this.root = tree;
+      return this;
+    }
+
+    public MarkdownToHtml build() {
+      return new MarkdownToHtml(this);
+    }
+  }
+
+  private final HtmlBuilder html = new HtmlBuilder();
+  private final TocFormatter toc = new TocFormatter(html, 3);
+  private final String requestUri;
+  private final GitilesView view;
+  private final MarkdownConfig config;
+  private final String filePath;
+  private final ImageLoader imageLoader;
+  private boolean outputNamedAnchor = true;
+
+  private MarkdownToHtml(Builder b) {
+    requestUri = b.requestUri;
+    view = b.view;
+    config = b.config;
+    filePath = b.filePath;
+    imageLoader = newImageLoader(b);
+  }
+
+  private static ImageLoader newImageLoader(Builder b) {
+    if (b.reader != null && b.view != null && b.config != null && b.root != null) {
+      return new ImageLoader(b.reader, b.view, b.config, b.root);
+    }
+    return null;
   }
 
   /** Render the document AST to sanitized HTML. */
@@ -148,7 +194,8 @@
     if (HtmlBuilder.isValidHttpUri(node.src)
         && HtmlBuilder.isValidCssDimension(node.height)
         && HtmlBuilder.isValidCssDimension(node.width)
-        && canRender(node)) {
+        && config != null
+        && config.isIFrameAllowed(node.src)) {
       html.open("iframe")
           .attribute("src", node.src)
           .attribute("height", node.height)
@@ -160,19 +207,6 @@
     }
   }
 
-  private boolean canRender(IframeBlock node) {
-    String[] ok = cfg.getStringList("markdown", null, "allowiframe");
-    if (ok.length == 1 && StringUtils.toBooleanOrNull(ok[0]) == Boolean.TRUE) {
-      return true;
-    }
-    for (String m : ok) {
-      if (m.equals(node.src) || (m.endsWith("/") && node.src.startsWith(m))) {
-        return true;
-      }
-    }
-    return false; // By default do not render iframe.
-  }
-
   @Override
   public void visit(Heading node) {
     outputNamedAnchor = false;
@@ -341,61 +375,37 @@
       target = target.substring(0, hash);
     }
 
-    if (target.startsWith("/")) {
-      return toPath(target) + anchor;
+    String dest = PathResolver.resolve(filePath, target);
+    if (dest == null || view == null) {
+      return FilterNormalizeUri.INSTANCE.getInnocuousOutput();
     }
 
-    String dir = trimLastComponent(filePath);
-    while (!target.isEmpty()) {
-      if (target.startsWith("../") || target.equals("..")) {
-        if (dir.isEmpty()) {
-          return FilterNormalizeUri.INSTANCE.getInnocuousOutput();
-        }
-        dir = trimLastComponent(dir);
-        target = target.equals("..") ? "" : target.substring(3);
-      } else if (target.startsWith("./")) {
-        target = target.substring(2);
-      } else if (target.equals(".")) {
-        target = "";
-      } else {
-        break;
-      }
-    }
-
-    return toPath(dir + '/' + target) + anchor;
-  }
-
-  private static String trimLastComponent(String path) {
-    int slash = path.lastIndexOf('/');
-    return slash < 0 ? "" : path.substring(0, slash);
-  }
-
-  private String toPath(String path) {
     GitilesView.Builder b;
     if (view.getType() == GitilesView.Type.ROOTED_DOC) {
       b = GitilesView.rootedDoc();
     } else {
       b = GitilesView.path();
     }
-    return b.copyFrom(view).setPathPart(path).build().toUrl();
+    dest = b.copyFrom(view).setPathPart(dest).build().toUrl();
+
+    return PathResolver.relative(requestUri, dest) + anchor;
   }
 
   @Override
   public void visit(Image node) {
     html.open("img")
-        .attribute("src", resolveImageUrl(node.getDestination()))
+        .attribute("src", image(node.getDestination()))
         .attribute("title", node.getTitle())
         .attribute("alt", getInnerText(node));
   }
 
-  private String resolveImageUrl(String url) {
-    if (imageLoader == null
-        || url.startsWith("https://")
-        || url.startsWith("http://")
-        || url.startsWith("data:")) {
-      return url;
+  String image(String dest) {
+    if (HtmlBuilder.isValidHttpUri(dest) || HtmlBuilder.isImageDataUri(dest)) {
+      return dest;
+    } else if (imageLoader != null) {
+      return imageLoader.inline(filePath, dest);
     }
-    return imageLoader.loadImage(url);
+    return FilterImageDataUri.INSTANCE.getInnocuousOutput();
   }
 
   public void visit(TableBlock node) {
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 c1ed246..5affd6c 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
@@ -15,11 +15,12 @@
 package com.google.gitiles.doc;
 
 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 com.google.template.soy.shared.restricted.EscapingConventions.FilterImageDataUri;
 
 import org.commonmark.node.Heading;
 import org.commonmark.node.Node;
+import org.eclipse.jgit.util.RawParseUtils;
 
 import java.util.HashMap;
 import java.util.Map;
@@ -30,61 +31,83 @@
   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);
-    data.put("homeUrl", null);
+  private MarkdownToHtml fmt;
+  private Node node;
+  private String siteTitle;
+  private String logoUrl;
+  private String homeUrl;
 
-    if (nav == null) {
-      return data;
+  Navbar() {}
+
+  Navbar setFormatter(MarkdownToHtml html) {
+    this.fmt = html;
+    return this;
+  }
+
+  Navbar setMarkdown(byte[] md) {
+    if (md != null && md.length > 0) {
+      parse(RawParseUtils.decode(md));
+    }
+    return this;
+  }
+
+  Map<String, Object> toSoyData() {
+    Map<String, Object> data = new HashMap<>();
+    data.put("siteTitle", siteTitle);
+    data.put("logoUrl", logo());
+    data.put("homeUrl", homeUrl != null ? fmt.href(homeUrl) : null);
+    data.put("navbarHtml", node != null ? fmt.toSoyHtml(node) : null);
+    return data;
+  }
+
+  private Object logo() {
+    if (logoUrl == null) {
+      return null;
     }
 
-    for (Node c = nav.getFirstChild(); c != null; c = c.getNext()) {
+    String url = fmt.image(logoUrl);
+    if (HtmlBuilder.isValidHttpUri(url)) {
+      return url;
+    } else if (HtmlBuilder.isImageDataUri(url)) {
+      return Sanitizers.filterImageDataUri(url);
+    } else {
+      return FilterImageDataUri.INSTANCE.getInnocuousOutput();
+    }
+  }
+
+  private void parse(String markdown) {
+    node = GitilesMarkdown.parse(markdown);
+
+    extractSiteTitle();
+    extractRefLinks(markdown);
+  }
+
+  private void extractSiteTitle() {
+    for (Node c = node.getFirstChild(); c != null; c = c.getNext()) {
       if (c instanceof Heading) {
         Heading h = (Heading) c;
         if (h.getLevel() == 1) {
-          data.put("siteTitle", MarkdownUtil.getInnerText(h));
+          siteTitle = MarkdownUtil.getInnerText(h);
           h.unlink();
           break;
         }
       }
     }
+  }
 
-    Matcher m = REF_LINK.matcher(navMarkdown);
+  private void extractRefLinks(String markdown) {
+    Matcher m = REF_LINK.matcher(markdown);
     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));
+          logoUrl = url;
           break;
-
         case "home":
-          data.put("homeUrl", toHtml.href(url));
+          homeUrl = 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/PathResolver.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/PathResolver.java
new file mode 100644
index 0000000..1cc8095
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/PathResolver.java
@@ -0,0 +1,89 @@
+// 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.CharMatcher;
+
+import javax.annotation.Nullable;
+
+class PathResolver {
+  /**
+   * Resolve a path within the repository.
+   *
+   * @param file path of the Markdown file in the repository that is making the
+   *        reference. May be null.
+   * @param target destination within the repository. If {@code target} starts
+   *        with {@code '/'}, {@code file} may be null and {@code target} is
+   *        evaluated as from the root directory of the repository.
+   * @return resolved form of {@code target} within the repository. Null if
+   *         {@code target} is not valid from {@code file}. Does not begin with
+   *         {@code '/'}, even if {@code target} does.
+   */
+  @Nullable
+  static String resolve(@Nullable String file, String target) {
+    if (target.startsWith("/")) {
+      return trimLeadingSlash(target);
+    } else if (file == null) {
+      return null;
+    }
+
+    String dir = trimLastComponent(trimLeadingSlash(file));
+    while (!target.isEmpty()) {
+      if (target.startsWith("../") || target.equals("..")) {
+        if (dir.isEmpty()) {
+          return null;
+        }
+        dir = trimLastComponent(dir);
+        target = target.equals("..") ? "" : target.substring(3);
+      } else if (target.startsWith("./")) {
+        target = target.substring(2);
+      } else if (target.equals(".")) {
+        target = "";
+      } else {
+        break;
+      }
+    }
+    return trimLeadingSlash(dir + '/' + target);
+  }
+
+  private static String trimLeadingSlash(String s) {
+    return CharMatcher.is('/').trimLeadingFrom(s);
+  }
+
+  private static String trimLastComponent(String path) {
+    int slash = path.lastIndexOf('/');
+    return slash < 0 ? "" : path.substring(0, slash);
+  }
+
+  static String relative(@Nullable String requestUri, String dest) {
+    if (requestUri != null) {
+      // base is the path the browser will use for relative URLs.
+      String base = requestUri;
+      if (!base.endsWith("/")) {
+        int slash = base.lastIndexOf('/');
+        if (slash < 0) {
+          return dest;
+        }
+        base = base.substring(0, slash + 1);
+      }
+      if (dest.startsWith(base)) {
+        return dest.substring(base.length());
+      }
+    }
+    return dest;
+  }
+
+  private PathResolver() {}
+}
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/mime-types.properties b/gitiles-servlet/src/main/resources/com/google/gitiles/mime-types.properties
new file mode 100644
index 0000000..0b4bd26
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/mime-types.properties
@@ -0,0 +1,16 @@
+bmp = image/bmp
+css = text/css
+csv = text/csv
+gif = image/gif
+htm = text/html
+html = text/html
+jpeg = image/jpeg
+jpg = image/jpeg
+js = application/javascript
+md = text/markdown
+pdf = application/pdf
+png = image/png
+svg = image/svg+xml
+tiff = image/tiff
+txt = text/plain
+xml = text/xml
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/doc/LinkTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/doc/LinkTest.java
index 51154b0..fdffffc 100644
--- a/gitiles-servlet/src/test/java/com/google/gitiles/doc/LinkTest.java
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/doc/LinkTest.java
@@ -44,7 +44,12 @@
 
   @Test
   public void httpLink() {
-    MarkdownToHtml md = new MarkdownToHtml(view, config, "index.md");
+    MarkdownToHtml md =
+        MarkdownToHtml.builder()
+            .setGitilesView(view)
+            .setConfig(new MarkdownConfig(config))
+            .setFilePath("index.md")
+            .build();
     String url;
 
     url = "http://example.com/foo.html";
@@ -59,7 +64,12 @@
 
   @Test
   public void absolutePath() {
-    MarkdownToHtml md = new MarkdownToHtml(view, config, "index.md");
+    MarkdownToHtml md =
+        MarkdownToHtml.builder()
+            .setGitilesView(view)
+            .setConfig(new MarkdownConfig(config))
+            .setFilePath("index.md")
+            .build();
 
     assertThat(md.href("/")).isEqualTo("/g/repo/+/HEAD/");
     assertThat(md.href("/index.md")).isEqualTo("/g/repo/+/HEAD/index.md");
@@ -133,10 +143,11 @@
   }
 
   private MarkdownToHtml file(String path) {
-    return new MarkdownToHtml(
-        GitilesView.doc().copyFrom(view).setPathPart(path).build(),
-        config,
-        path);
+    return MarkdownToHtml.builder()
+        .setGitilesView(GitilesView.doc().copyFrom(view).setPathPart(path).build())
+        .setConfig(new MarkdownConfig(config))
+        .setFilePath(path)
+        .build();
   }
 
   private MarkdownToHtml repoIndexReadme() {
@@ -154,7 +165,11 @@
   }
 
   private MarkdownToHtml readme(GitilesView v, String path) {
-    return new MarkdownToHtml(v, config, path);
+    return MarkdownToHtml.builder()
+        .setGitilesView(v)
+        .setConfig(new MarkdownConfig(config))
+        .setFilePath(path)
+        .build();
   }
 
   @Test
@@ -200,12 +215,36 @@
   }
 
   private MarkdownToHtml rootedDoc(String path, String file) {
-    GitilesView view = GitilesView.rootedDoc()
-        .setHostName("gerritcodereview.com")
-        .setServletPath("")
-        .setRevision(RootedDocServlet.BRANCH)
-        .setPathPart(path)
+    return MarkdownToHtml.builder()
+        .setGitilesView(
+            GitilesView.rootedDoc()
+                .setHostName("gerritcodereview.com")
+                .setServletPath("")
+                .setRevision(RootedDocServlet.BRANCH)
+                .setPathPart(path)
+                .build())
+        .setConfig(new MarkdownConfig(config))
+        .setFilePath(file)
         .build();
-    return new MarkdownToHtml(view, config, file);
+  }
+
+  @Test
+  public void automaticRelativePaths() {
+    MarkdownToHtml md =
+        MarkdownToHtml.builder()
+            .setGitilesView(GitilesView.doc().copyFrom(view).setPathPart("docs/index.md").build())
+            .setConfig(new MarkdownConfig(config))
+            .setFilePath("/docs/index.md")
+            .setRequestUri("/g/repo/+/HEAD/docs/index.md")
+            .build();
+
+    assertThat(md.href("help.md")).isEqualTo("help.md");
+    assertThat(md.href("/docs/help.md")).isEqualTo("help.md");
+    assertThat(md.href("technical/format.md")).isEqualTo("technical/format.md");
+    assertThat(md.href("/docs/technical/format.md")).isEqualTo("technical/format.md");
+
+    assertThat(md.href("../README.md")).isEqualTo("/g/repo/+/HEAD/README.md");
+    assertThat(md.href("../src/catalog.md")).isEqualTo("/g/repo/+/HEAD/src/catalog.md");
+    assertThat(md.href("/src/catalog.md")).isEqualTo("/g/repo/+/HEAD/src/catalog.md");
   }
 }
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/doc/PathResolverTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/doc/PathResolverTest.java
new file mode 100644
index 0000000..f1990ec
--- /dev/null
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/doc/PathResolverTest.java
@@ -0,0 +1,55 @@
+// 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.common.truth.Truth.assertThat;
+import static com.google.gitiles.doc.PathResolver.relative;
+import static com.google.gitiles.doc.PathResolver.resolve;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class PathResolverTest {
+  @Test
+  public void resolveTests() {
+    assertThat(resolve(null, "/foo.md")).isEqualTo("foo.md");
+    assertThat(resolve(null, "////foo.md")).isEqualTo("foo.md");
+    assertThat(resolve("/index.md", "/foo.md")).isEqualTo("foo.md");
+    assertThat(resolve("index.md", "/foo.md")).isEqualTo("foo.md");
+    assertThat(resolve("index.md", "foo.md")).isEqualTo("foo.md");
+    assertThat(resolve(null, "foo.md")).isNull();
+
+    assertThat(resolve("doc/index.md", "../foo.md")).isEqualTo("foo.md");
+    assertThat(resolve("/doc/index.md", "../foo.md")).isEqualTo("foo.md");
+    assertThat(resolve("/doc/index.md", ".././foo.md")).isEqualTo("foo.md");
+    assertThat(resolve("/a/b/c/index.md", "../../foo.md")).isEqualTo("a/foo.md");
+    assertThat(resolve("/a/index.md", "../../../foo.md")).isNull();
+  }
+
+  @Test
+  public void relativeTests() {
+    assertThat(relative(null, "/g/foo.md")).isEqualTo("/g/foo.md");
+
+    assertThat(relative("/g", "/g/foo.md")).isEqualTo("g/foo.md");
+    assertThat(relative("/r", "/g/foo.md")).isEqualTo("g/foo.md");
+    assertThat(relative("/a/b/r", "/a/b/g/foo.md")).isEqualTo("g/foo.md");
+
+    assertThat(relative("/g/", "/g/foo.md")).isEqualTo("foo.md");
+    assertThat(relative("/g/bar.md", "/g/foo.md")).isEqualTo("foo.md");
+    assertThat(relative("/g/a/b.md", "/g/foo.md")).isEqualTo("/g/foo.md");
+  }
+}