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");
+ }
+}