Markdown: render pages under /+doc/ Parse and render any file ending with ".md" as markdown using the pegdown processor. This allows a repository to be used as a documentation source. If "navbar.md" is found in the top level directory it is rendered as a horizontal bar across the top of the page. A navbar.md is an outline: * [Home](/index.md) * [APIs](/api/index.md) * [Source](/src/main/java/index.md) Any links starting with "/" and ending in ".md" are resolved as relative to the top of the Git repository. As an extension to Markdown the special block [TOC] is replaced with a table of contents from the headers of the text. Markdown support can be optionally disabled by setting the config variable markdown.render to false. Change-Id: Ic111628c59cfadfdca37bf0cc637ee8a14d54c37
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java index 2a8bf86..ae13a31 100644 --- a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java +++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java
@@ -30,6 +30,7 @@ import com.google.gitiles.blame.BlameCache; import com.google.gitiles.blame.BlameCacheImpl; import com.google.gitiles.blame.BlameServlet; +import com.google.gitiles.doc.DocServlet; import org.eclipse.jgit.errors.ConfigInvalidException; import org.eclipse.jgit.errors.RepositoryNotFoundException; @@ -255,6 +256,8 @@ return new ArchiveServlet(accessFactory); case BLAME: return new BlameServlet(accessFactory, renderer, blameCache); + case DOC: + return new DocServlet(accessFactory, renderer); default: throw new IllegalArgumentException("Invalid view type: " + view); }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java index 1a0e1af..64c5177 100644 --- a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java +++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java
@@ -65,7 +65,8 @@ LOG, DESCRIBE, ARCHIVE, - BLAME; + BLAME, + DOC; } /** Exception thrown when building a view that is invalid. */ @@ -104,6 +105,7 @@ oldRevision = other.oldRevision; // Fallthrough. case PATH: + case DOC: case ARCHIVE: case BLAME: path = other.path; @@ -232,6 +234,7 @@ case DESCRIBE: case REFS: case LOG: + case DOC: break; default: checkState(path == null, "cannot set path on %s view", type); @@ -323,6 +326,9 @@ case BLAME: checkBlame(); break; + case DOC: + checkDoc(); + break; } return new GitilesView(type, hostName, servletPath, repositoryName, revision, oldRevision, path, extension, params, anchor); @@ -381,6 +387,10 @@ private void checkBlame() { checkPath(); } + + private void checkDoc() { + checkRevision(); + } } public static Builder hostIndex() { @@ -423,6 +433,10 @@ return new Builder(Type.BLAME); } + public static Builder doc() { + return new Builder(Type.DOC); + } + static String maybeTrimLeadingAndTrailingSlash(String str) { if (str.startsWith("/")) { str = str.substring(1); @@ -610,6 +624,12 @@ url.append(repositoryName).append("/+blame/").append(revision.getName()).append('/') .append(path); break; + case DOC: + url.append(repositoryName).append("/+doc/").append(revision.getName()); + if (path != null) { + url.append('/').append(path); + } + break; default: throw new IllegalStateException("Unknown view type: " + type); }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/Renderer.java b/gitiles-servlet/src/main/java/com/google/gitiles/Renderer.java index c9e5e80..7aec30f 100644 --- a/gitiles-servlet/src/main/java/com/google/gitiles/Renderer.java +++ b/gitiles-servlet/src/main/java/com/google/gitiles/Renderer.java
@@ -59,6 +59,7 @@ "BlameDetail.soy", "Common.soy", "DiffDetail.soy", + "Doc.soy", "HostIndex.soy", "LogDetail.soy", "ObjectDetail.soy", @@ -69,6 +70,7 @@ public static final Map<String, String> STATIC_URL_GLOBALS = ImmutableMap.of( "gitiles.CSS_URL", "gitiles.css", + "gitiles.DOC_CSS_URL", "doc.css", "gitiles.PRETTIFY_CSS_URL", "prettify/prettify.css"); protected static class FileUrlMapper implements Function<String, URL> { @@ -147,6 +149,10 @@ return h.hash(); } + public String render(String templateName, Map<String, ?> soyData) { + return newRenderer(templateName).setData(soyData).render(); + } + void render(HttpServletRequest req, HttpServletResponse res, String templateName, Map<String, ?> soyData) throws IOException { res.setContentType("text/html");
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java b/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java index e8b1055..99259d6 100644 --- a/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java +++ b/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java
@@ -52,6 +52,7 @@ private static final String CMD_LOG = "+log"; private static final String CMD_REFS = "+refs"; private static final String CMD_SHOW = "+show"; + private static final String CMD_DOC = "+doc"; public static GitilesView getView(HttpServletRequest req) { return (GitilesView) req.getAttribute(VIEW_ATTRIBUTE); @@ -164,6 +165,8 @@ return parseRefsCommand(repoName, path); } else if (command.equals(CMD_SHOW)) { return parseShowCommand(req, repoName, path); + } else if (command.equals(CMD_DOC)) { + return parseDocCommand(req, repoName, path); } else { return null; } @@ -300,6 +303,25 @@ } } + private GitilesView.Builder parseDocCommand( + HttpServletRequest req, String repoName, String path) throws IOException { + return parseDocCommand(repoName, parseRevision(req, path)); + } + + private GitilesView.Builder parseDocCommand( + String repoName, RevisionParser.Result result) { + if (result == null || result.getOldRevision() != null) { + return null; + } + GitilesView.Builder b = GitilesView.doc() + .setRepositoryName(repoName) + .setRevision(result.getRevision()); + if (!result.getPath().isEmpty()) { + b.setPathPart(result.getPath()); + } + return b; + } + private RevisionParser.Result parseRevision(HttpServletRequest req, String path) throws IOException { RevisionParser revParser = new RevisionParser(
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 new file mode 100644 index 0000000..2718e6e --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/DocServlet.java
@@ -0,0 +1,244 @@ +// Copyright 2015 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gitiles.doc; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR; +import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND; +import static javax.servlet.http.HttpServletResponse.SC_NOT_MODIFIED; +import static org.eclipse.jgit.lib.Constants.OBJ_BLOB; +import static org.eclipse.jgit.lib.FileMode.TYPE_FILE; +import static org.eclipse.jgit.lib.FileMode.TYPE_MASK; +import static org.eclipse.jgit.lib.FileMode.TYPE_TREE; + +import com.google.common.base.MoreObjects; +import com.google.common.base.Strings; +import com.google.common.hash.Hasher; +import com.google.common.hash.Hashing; +import com.google.common.net.HttpHeaders; +import com.google.gitiles.BaseServlet; +import com.google.gitiles.FormatType; +import com.google.gitiles.GitilesAccess; +import com.google.gitiles.GitilesView; +import com.google.gitiles.Renderer; +import com.google.gitiles.ViewFilter; + +import org.eclipse.jgit.errors.IncorrectObjectTypeException; +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.pegdown.ast.RootNode; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +public class DocServlet extends BaseServlet { + private static final long serialVersionUID = 1L; + + private static final String INDEX_MD = "index.md"; + private static final String NAVBAR_MD = "navbar.md"; + private static final String SOY_FILE = "Doc.soy"; + private static final String SOY_TEMPLATE = "gitiles.markdownDoc"; + + // Generation of ETag logic. Bump this only if DocServlet logic changes + // significantly enough to impact cached pages. Soy template and source + // files are automatically hashed as part of the ETag. + private static final int ETAG_GEN = 1; + + public DocServlet(GitilesAccess.Factory accessFactory, Renderer renderer) { + super(renderer, accessFactory); + } + + @Override + protected void doGetHtml(HttpServletRequest req, HttpServletResponse res) + throws IOException { + Config cfg = getAccess(req).getConfig(); + if (!cfg.getBoolean("markdown", "render", true)) { + res.setStatus(SC_NOT_FOUND); + return; + } + + GitilesView view = ViewFilter.getView(req); + Repository repo = ServletUtils.getRepository(req); + RevWalk rw = new RevWalk(repo); + try { + String path = view.getPathPart(); + RevTree root; + try { + root = rw.parseTree(view.getRevision().getId()); + } catch (IncorrectObjectTypeException e) { + res.setStatus(SC_NOT_FOUND); + return; + } + + SourceFile 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); + String curEtag = etag(srcmd, navmd); + if (reqEtag != null && reqEtag.equals(curEtag)) { + res.setStatus(SC_NOT_MODIFIED); + return; + } + + view = view.toBuilder().setPathPart(srcmd.path).build(); + int inputLimit = cfg.getInt("markdown", "inputLimit", 5 << 20); + RootNode doc = GitilesMarkdown.parseFile( + view, srcmd.path, + srcmd.read(rw.getObjectReader(), inputLimit)); + if (doc == null) { + res.setStatus(SC_INTERNAL_SERVER_ERROR); + return; + } + + RootNode nav = null; + if (navmd != null) { + nav = GitilesMarkdown.parseFile( + view, navmd.path, + navmd.read(rw.getObjectReader(), inputLimit)); + if (nav == null) { + res.setStatus(SC_INTERNAL_SERVER_ERROR); + return; + } + } + + res.setHeader(HttpHeaders.ETAG, curEtag); + showDoc(req, res, view, nav, doc); + } finally { + rw.release(); + } + } + + private String etag(SourceFile srcmd, SourceFile navmd) { + byte[] b = new byte[Constants.OBJECT_ID_LENGTH]; + Hasher h = Hashing.sha1().newHasher(); + h.putInt(ETAG_GEN); + + renderer.getTemplateHash(SOY_FILE).writeBytesTo(b, 0, b.length); + h.putBytes(b); + + if (navmd != null) { + navmd.id.copyRawTo(b, 0); + h.putBytes(b); + } + + srcmd.id.copyRawTo(b, 0); + h.putBytes(b); + return h.hash().toString(); + } + + @Override + protected void setCacheHeaders(HttpServletResponse res) { + long now = System.currentTimeMillis(); + res.setDateHeader(HttpHeaders.EXPIRES, now); + res.setDateHeader(HttpHeaders.DATE, now); + res.setHeader(HttpHeaders.CACHE_CONTROL, "private, max-age=0, must-revalidate"); + } + + private void showDoc(HttpServletRequest req, HttpServletResponse res, + GitilesView view, RootNode nav, RootNode doc) throws IOException { + Map<String, Object> data = new HashMap<>(); + data.put("pageTitle", MoreObjects.firstNonNull( + MarkdownHelper.getTitle(doc), + view.getPathPart())); + data.put("sourceUrl", GitilesView.path().copyFrom(view).toUrl()); + data.put("logUrl", GitilesView.log().copyFrom(view).toUrl()); + data.put("blameUrl", GitilesView.blame().copyFrom(view).toUrl()); + data.put("navbarHtml", new MarkdownToHtml(view).toSoyHtml(nav)); + data.put("bodyHtml", new MarkdownToHtml(view).toSoyHtml(doc)); + + String page = renderer.render(SOY_TEMPLATE, data); + byte[] raw = page.getBytes(UTF_8); + res.setContentType(FormatType.HTML.getMimeType()); + res.setCharacterEncoding(UTF_8.name()); + setCacheHeaders(res); + if (acceptsGzipEncoding(req)) { + res.setHeader(HttpHeaders.CONTENT_ENCODING, "gzip"); + raw = gzip(raw); + } + res.setContentLength(raw.length); + res.setStatus(HttpServletResponse.SC_OK); + res.getOutputStream().write(raw); + } + + private static SourceFile findFile(RevWalk rw, RevTree root, String path) throws IOException { + if (Strings.isNullOrEmpty(path)) { + path = INDEX_MD; + } + + ObjectReader reader = rw.getObjectReader(); + TreeWalk tw = TreeWalk.forPath(reader, path, root); + if (tw == null) { + return null; + } + if ((tw.getRawMode(0) & TYPE_MASK) == TYPE_TREE) { + if (findIndexFile(tw)) { + path = tw.getPathString(); + } else { + return null; + } + } + if ((tw.getRawMode(0) & TYPE_MASK) == TYPE_FILE) { + if (!path.endsWith(".md")) { + return null; + } + return new SourceFile(path, tw.getObjectId(0)); + } + return null; + } + + private static boolean findIndexFile(TreeWalk tw) throws IOException { + tw.enterSubtree(); + while (tw.next()) { + if ((tw.getRawMode(0) & TYPE_MASK) == TYPE_FILE + && INDEX_MD.equals(tw.getNameString())) { + return true; + } + } + return false; + } + + private static class SourceFile { + final String path; + final ObjectId id; + + SourceFile(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); + } + } +}
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 new file mode 100644 index 0000000..b79bf4d --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/GitilesMarkdown.java
@@ -0,0 +1,83 @@ +// Copyright 2015 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gitiles.doc; + +import com.google.gitiles.GitilesView; + +import org.parboiled.Rule; +import org.pegdown.Parser; +import org.pegdown.ParsingTimeoutException; +import org.pegdown.PegDownProcessor; +import org.pegdown.ast.RootNode; +import org.pegdown.plugins.BlockPluginParser; +import org.pegdown.plugins.PegDownPlugins; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Additional markdown extensions known to Gitiles. + * <p> + * {@code [TOC]} as a stand-alone block will insert a table of contents + * for the current document. + */ +class GitilesMarkdown extends Parser implements BlockPluginParser { + private static final Logger log = LoggerFactory.getLogger(MarkdownHelper.class); + + // SUPPRESS_ALL_HTML is enabled to permit hosting arbitrary user content + // while avoiding XSS style HTML, CSS and JavaScript injection attacks. + // + // HARDWRAPS is disabled to permit line wrapping within paragraphs to + // make the source file easier to read in 80 column terminals without + // this impacting the rendered formatting. + private static final int MD_OPTIONS = (ALL | SUPPRESS_ALL_HTML) & ~(HARDWRAPS); + + static RootNode parseFile(GitilesView view, String path, String md) { + if (md == null) { + return null; + } + + try { + return newParser().parseMarkdown(md.toCharArray()); + } catch (ParsingTimeoutException e) { + log.error("timeout rendering {}/{} at {}", + view.getRepositoryName(), + path, + view.getRevision().getName()); + return null; + } + } + + private static PegDownProcessor newParser() { + PegDownPlugins plugins = new PegDownPlugins.Builder() + .withPlugin(GitilesMarkdown.class) + .build(); + return new PegDownProcessor(MD_OPTIONS, plugins); + } + + GitilesMarkdown() { + super(MD_OPTIONS, 2000L, DefaultParseRunnerProvider); + } + + @Override + public Rule[] blockPluginRules() { + return new Rule[]{ toc() }; + } + + public Rule toc() { + return NodeSequence( + string("[TOC]"), + push(new TocNode())); + } +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownHelper.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownHelper.java index 6d67505..e7566f5 100644 --- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownHelper.java +++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownHelper.java
@@ -16,6 +16,7 @@ import com.google.common.base.Strings; +import org.pegdown.ast.HeaderNode; import org.pegdown.ast.Node; import org.pegdown.ast.TextNode; @@ -48,6 +49,23 @@ } } + static String getTitle(Node node) { + if (node instanceof HeaderNode) { + if (((HeaderNode) node).getLevel() == 1) { + return getInnerText(node); + } + return null; + } + + for (Node child : node.getChildren()) { + String title = getTitle(child); + if (title != null) { + return title; + } + } + return null; + } + private MarkdownHelper() { } }
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 bad8ab7..a3feb6c 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
@@ -17,6 +17,7 @@ import static com.google.common.base.Preconditions.checkState; import static com.google.gitiles.doc.MarkdownHelper.getInnerText; +import com.google.gitiles.GitilesView; import com.google.gitiles.doc.html.HtmlBuilder; import com.google.template.soy.data.SanitizedContent; import com.google.template.soy.shared.restricted.EscapingConventions; @@ -69,8 +70,13 @@ private final ReferenceMap references = new ReferenceMap(); private final HtmlBuilder html = new HtmlBuilder(); private final TocFormatter toc = new TocFormatter(html, 3); + private final GitilesView view; private TableState table; + public MarkdownToHtml(GitilesView view) { + this.view = view; + } + /** Render the document AST to sanitized HTML. */ public SanitizedContent toSoyHtml(RootNode node) { if (node == null) { @@ -180,7 +186,7 @@ @Override public void visit(AutoLinkNode node) { String url = node.getText(); - html.open("a").attribute("href", url) + html.open("a").attribute("href", href(url)) .appendAndEscape(url) .close("a"); } @@ -197,7 +203,7 @@ public void visit(WikiLinkNode node) { String text = node.getText(); String path = text.replace(' ', '-') + ".md"; - html.open("a").attribute("href", path) + html.open("a").attribute("href", href(path)) .appendAndEscape(text) .close("a"); } @@ -205,7 +211,7 @@ @Override public void visit(ExpLinkNode node) { html.open("a") - .attribute("href", node.url) + .attribute("href", href(node.url)) .attribute("title", node.title); visitChildren(node); html.close("a"); @@ -216,7 +222,7 @@ ReferenceNode ref = references.get(node.referenceKey, getInnerText(node)); if (ref != null) { html.open("a") - .attribute("href", ref.getUrl()) + .attribute("href", href(ref.getUrl())) .attribute("title", ref.getTitle()); visitChildren(node); html.close("a"); @@ -226,6 +232,13 @@ } } + private String href(String url) { + if (MarkdownHelper.isAbsolutePathToMarkdown(url)) { + return GitilesView.doc().copyFrom(view).setPathPart(url).build().toUrl(); + } + return url; + } + @Override public void visit(ExpImageNode node) { html.open("img")
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/doc.css b/gitiles-servlet/src/main/resources/com/google/gitiles/static/doc.css new file mode 100644 index 0000000..e047f59 --- /dev/null +++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/doc.css
@@ -0,0 +1,167 @@ +/** + * Copyright 2015 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +html { + font-family: arial,sans-serif; +} +body { + margin: 0; +} + + +.nav, .footer-line { + color: #333; + padding: 0 15px; + background: #eee; +} +.nav ul { + list-style: none; + margin: 0; + padding: 6px 0; +} +.nav li { + float: left; + font-size: 14px; + line-height: 1.43; + margin: 0 20px 0 0; + padding: 6px 0; +} +.nav li a, .footer-line a { + color: #7a7af9; +} +.nav li a:hover { + color: #0000f9; +} +.nav ul:after { + clear: both; + content: ""; + display: block; +} + +.nav-aux, .content { + margin: auto; + max-width: 978px; +} + +.footer-break { + clear: both; + margin: 120px 0 0 0; +} +.footer-line { + font-size: 13px; + line-height: 30px; + height: 30px; +} +.footer-line ul { + list-style: none; + margin: 0; + padding: 0; +} +.footer-line li { + display: inline; +} +.footer-line li+li:before { + content: "·"; + padding: 0 5px; +} +.footer-line .nav-aux { + position: relative; +} +.gitiles-att { + color: #A0ADCC; + position: absolute; + top: 0; + right: 0; +} +.gitiles-att a { + font-style: italic; +} + +.toc { + margin-top: 30px; +} +.toc-aux { + padding: 2px; + background: #f9f9f9; + border: 1px solid #f2f2f2; + border-radius: 4px; +} +.toc h2 { + margin: 0 0 5px 0; +} +.toc ul { + margin: 0 0 0 30px; +} +.toc ul li { + margin-left: 0px; + list-style: disc; +} +.toc ul ul li { + list-style: circle; +} + +.content { + color: #444; + font-size: 13px; +} + +h1, h2, h3, h4, h5, h6 { + font-family: "open sans",arial,sans-serif; +} +h1, h2, h3, h4 { font-weight: bold; } +h5, h6 { font-weight: normal; } +h1 { font-size: 20px; } +h2 { font-size: 16px; } +h3 { font-size: 14px; } +h4, h5, h6 { font-size: 13px; } + +a { text-decoration: none; } +a:link { color: #245dc1; } +a:visited { color: #7759ae; } +a:hover { text-decoration: underline; } + +ul, ol { + margin: 10px 10px 10px 30px; + padding: 0; +} + +pre { + border: 1px solid silver; + background: #fafafa; + margin: 0 2em 0 2em; + padding: 2px; +} +code, .code { + color: #060; + font: 13px/1.54 "courier new",courier,monospace; +} + +dl dt { + margin-top: 1em; +} + +table { + border-collapse: collapse; + border-spacing: 0; +} +th, td { + border: 1px solid #eee; + padding: 4px 12px; + vertical-align: top; +} +th { + background-color: #f5f5f5; +} \ No newline at end of file
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/Doc.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/Doc.soy new file mode 100644 index 0000000..530573d --- /dev/null +++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/Doc.soy
@@ -0,0 +1,59 @@ +// Copyright 2015 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +{namespace gitiles autoescape="strict"} + +/** + * Documentation page rendered from markdown. + * + * @param pageTitle h1 title from the documentation. + * @param sourceUrl url for source view of the page. + * @param logUrl url for log history of page. + * @param blameUrl url for blame of page source. + * @param? navbarHtml markdown ast node to convert. + * @param bodyHtml safe html to embed into the body of the page. + */ +{template .markdownDoc} +<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd"> +<html> +<head> + <title>{$pageTitle}</title> + <link rel="stylesheet" type="text/css" href="{gitiles.DOC_CSS_URL}" /> +</head> +<body> + {if $navbarHtml} + <div class="nav" role="navigation"> + <div class="nav-aux"> + {$navbarHtml} + </div> + </div> + {/if} + <div class="content"> + {$bodyHtml} + </div> + <div class="footer-break"></div> + <div class="footer-line"> + <div class="nav-aux"> + <ul> + <li><a href="{$sourceUrl}">{msg desc="text for the source link"}source{/msg}</a></li> + <li><a href="{$logUrl}">{msg desc="text for the log link"}log{/msg}</a></li> + <li><a href="{$blameUrl}">{msg desc="text for the blame link"}blame{/msg}</a></li> + </ul> + <div class="gitiles-att"> + Powered by <a href="https://code.google.com/p/gitiles/">Gitiles</a> + </div> + </div> + </div> +</body> +</html> +{/template}
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/ViewFilterTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/ViewFilterTest.java index eff0e59..acec4eb 100644 --- a/gitiles-servlet/src/test/java/com/google/gitiles/ViewFilterTest.java +++ b/gitiles-servlet/src/test/java/com/google/gitiles/ViewFilterTest.java
@@ -256,6 +256,35 @@ } @Test + public void doc() throws Exception { + RevCommit master = repo.branch("refs/heads/master").commit().create(); + repo.branch("refs/heads/stable").commit().create(); + GitilesView view; + + view = getView("/repo/+doc/master/"); + assertEquals(Type.DOC, view.getType()); + assertEquals(master, view.getRevision().getId()); + assertEquals("", view.getPathPart()); + + view = getView("/repo/+doc/master/index.md"); + assertEquals(Type.DOC, view.getType()); + assertEquals(master, view.getRevision().getId()); + assertEquals("index.md", view.getPathPart()); + + view = getView("/repo/+doc/master/foo/"); + assertEquals(Type.DOC, view.getType()); + assertEquals(master, view.getRevision().getId()); + assertEquals("foo", view.getPathPart()); + + view = getView("/repo/+doc/master/foo/bar.md"); + assertEquals(Type.DOC, view.getType()); + assertEquals(master, view.getRevision().getId()); + assertEquals("foo/bar.md", view.getPathPart()); + + assertNull(getView("/repo/+doc/stable..master/foo")); + } + + @Test public void multipleSlashes() throws Exception { repo.branch("refs/heads/master").commit().create(); assertEquals(Type.HOST_INDEX, getView("//").getType());