blob: a9d19dfd85c3db68341eec347991ddf61a8f5ee1 [file] [log] [blame] [raw]
// 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 com.google.common.base.Preconditions.checkState;
import static com.google.gitiles.doc.MarkdownUtil.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.FilterImageDataUri;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.util.StringUtils;
import org.pegdown.ast.AbbreviationNode;
import org.pegdown.ast.AutoLinkNode;
import org.pegdown.ast.BlockQuoteNode;
import org.pegdown.ast.BulletListNode;
import org.pegdown.ast.CodeNode;
import org.pegdown.ast.DefinitionListNode;
import org.pegdown.ast.DefinitionNode;
import org.pegdown.ast.DefinitionTermNode;
import org.pegdown.ast.ExpImageNode;
import org.pegdown.ast.ExpLinkNode;
import org.pegdown.ast.HeaderNode;
import org.pegdown.ast.HtmlBlockNode;
import org.pegdown.ast.InlineHtmlNode;
import org.pegdown.ast.ListItemNode;
import org.pegdown.ast.MailLinkNode;
import org.pegdown.ast.Node;
import org.pegdown.ast.OrderedListNode;
import org.pegdown.ast.ParaNode;
import org.pegdown.ast.QuotedNode;
import org.pegdown.ast.RefImageNode;
import org.pegdown.ast.RefLinkNode;
import org.pegdown.ast.ReferenceNode;
import org.pegdown.ast.RootNode;
import org.pegdown.ast.SimpleNode;
import org.pegdown.ast.SpecialTextNode;
import org.pegdown.ast.StrikeNode;
import org.pegdown.ast.StrongEmphSuperNode;
import org.pegdown.ast.SuperNode;
import org.pegdown.ast.TableBodyNode;
import org.pegdown.ast.TableCaptionNode;
import org.pegdown.ast.TableCellNode;
import org.pegdown.ast.TableColumnNode;
import org.pegdown.ast.TableHeaderNode;
import org.pegdown.ast.TableNode;
import org.pegdown.ast.TableRowNode;
import org.pegdown.ast.TextNode;
import org.pegdown.ast.VerbatimNode;
import org.pegdown.ast.WikiLinkNode;
/**
* Formats parsed markdown AST into HTML.
* <p>
* Callers must create a new instance for each RootNode.
*/
public class MarkdownToHtml implements Visitor {
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 final Config cfg;
private ImageLoader imageLoader;
private TableState table;
public MarkdownToHtml(GitilesView view, Config cfg) {
this.view = view;
this.cfg = cfg;
}
public MarkdownToHtml setImageLoader(ImageLoader img) {
imageLoader = img;
return this;
}
/** Render the document AST to sanitized HTML. */
public SanitizedContent toSoyHtml(RootNode node) {
if (node == null) {
return null;
}
toc.setRoot(node);
node.accept(this);
return html.toSoy();
}
@Override
public void visit(RootNode node) {
references.add(node);
visitChildren(node);
}
@Override
public void visit(TocNode node) {
toc.format();
}
@Override
public void visit(DivNode node) {
html.open("div").attribute("class", node.getStyleName());
visitChildren(node);
html.close("div");
}
@Override
public void visit(ColsNode node) {
html.open("div").attribute("class", "cols");
visitChildren(node);
html.close("div");
}
@Override
public void visit(ColsNode.Column node) {
if (1 <= node.span && node.span <= ColsNode.GRID_WIDTH) {
html.open("div").attribute("class", "col-" + node.span);
visitChildren(node);
html.close("div");
}
}
@Override
public void visit(IframeNode node) {
if (HtmlBuilder.isValidHttpUri(node.src)
&& HtmlBuilder.isValidCssDimension(node.height)
&& HtmlBuilder.isValidCssDimension(node.width)
&& canRender(node)) {
html.open("iframe")
.attribute("src", node.src)
.attribute("height", node.height)
.attribute("width", node.width);
if (!node.border) {
html.attribute("class", "noborder");
}
html.close("iframe");
}
}
private boolean canRender(IframeNode 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(HeaderNode node) {
String tag = "h" + node.getLevel();
html.open(tag);
if (toc.include(node)) {
html.attribute("id", toc.idFromHeader(node));
}
visitChildren(node);
html.close(tag);
}
@Override
public void visit(ParaNode node) {
wrapChildren("p", node);
}
@Override
public void visit(BlockQuoteNode node) {
wrapChildren("blockquote", node);
}
@Override
public void visit(OrderedListNode node) {
wrapChildren("ol", node);
}
@Override
public void visit(BulletListNode node) {
wrapChildren("ul", node);
}
@Override
public void visit(ListItemNode node) {
wrapChildren("li", node);
}
@Override
public void visit(DefinitionListNode node) {
wrapChildren("dl", node);
}
@Override
public void visit(DefinitionNode node) {
wrapChildren("dd", node);
}
@Override
public void visit(DefinitionTermNode node) {
wrapChildren("dt", node);
}
@Override
public void visit(VerbatimNode node) {
html.open("pre").attribute("class", "code");
String text = node.getText();
while (text.startsWith("\n")) {
html.open("br");
text = text.substring(1);
}
html.appendAndEscape(text);
html.close("pre");
}
@Override
public void visit(CodeNode node) {
wrapText("code", node);
}
@Override
public void visit(StrikeNode node) {
wrapChildren("del", node);
}
@Override
public void visit(StrongEmphSuperNode node) {
if (node.isClosed()) {
wrapChildren(node.isStrong() ? "strong" : "em", node);
} else {
// Unclosed (or unmatched) sequence is plain text.
html.appendAndEscape(node.getChars());
visitChildren(node);
}
}
@Override
public void visit(AutoLinkNode node) {
String url = node.getText();
html.open("a").attribute("href", href(url))
.appendAndEscape(url)
.close("a");
}
@Override
public void visit(MailLinkNode node) {
String addr = node.getText();
html.open("a").attribute("href", "mailto:" + addr)
.appendAndEscape(addr)
.close("a");
}
@Override
public void visit(WikiLinkNode node) {
String text = node.getText();
String path = text.replace(' ', '-') + ".md";
html.open("a").attribute("href", href(path))
.appendAndEscape(text)
.close("a");
}
@Override
public void visit(ExpLinkNode node) {
html.open("a")
.attribute("href", href(node.url))
.attribute("title", node.title);
visitChildren(node);
html.close("a");
}
@Override
public void visit(RefLinkNode node) {
ReferenceNode ref = references.get(node.referenceKey, getInnerText(node));
if (ref != null) {
html.open("a")
.attribute("href", href(ref.getUrl()))
.attribute("title", ref.getTitle());
visitChildren(node);
html.close("a");
} else {
// Treat a broken RefLink as plain text.
html.appendAndEscape("[");
visitChildren(node);
html.appendAndEscape("]");
}
}
private String href(String url) {
if (MarkdownUtil.isAbsolutePathToMarkdown(url)) {
return GitilesView.doc().copyFrom(view).setPathPart(url).build().toUrl();
}
return url;
}
@Override
public void visit(ExpImageNode node) {
html.open("img")
.attribute("src", resolveImageUrl(node.url))
.attribute("title", node.title)
.attribute("alt", getInnerText(node));
}
@Override
public void visit(RefImageNode node) {
String alt = getInnerText(node);
String url, title = alt;
ReferenceNode ref = references.get(node.referenceKey, alt);
if (ref != null) {
url = resolveImageUrl(ref.getUrl());
title = ref.getTitle();
} else {
// If reference is missing, insert a broken image.
url = FilterImageDataUri.INSTANCE.getInnocuousOutput();
}
html.open("img")
.attribute("src", url)
.attribute("title", title)
.attribute("alt", alt);
}
private String resolveImageUrl(String url) {
if (imageLoader == null
|| url.startsWith("https://") || url.startsWith("http://")
|| url.startsWith("data:")) {
return url;
}
return imageLoader.loadImage(url);
}
@Override
public void visit(TableNode node) {
table = new TableState(node);
wrapChildren("table", node);
table = null;
}
private void mustBeInsideTable(Node node) {
checkState(table != null, "%s must be in table", node);
}
@Override
public void visit(TableHeaderNode node) {
mustBeInsideTable(node);
table.inHeader = true;
wrapChildren("thead", node);
table.inHeader = false;
}
@Override
public void visit(TableBodyNode node) {
wrapChildren("tbody", node);
}
@Override
public void visit(TableCaptionNode node) {
wrapChildren("caption", node);
}
@Override
public void visit(TableRowNode node) {
mustBeInsideTable(node);
table.startRow();
wrapChildren("tr", node);
}
@Override
public void visit(TableCellNode node) {
mustBeInsideTable(node);
String tag = table.inHeader ? "th" : "td";
html.open(tag)
.attribute("align", table.getAlign());
if (node.getColSpan() > 1) {
html.attribute("colspan", Integer.toString(node.getColSpan()));
}
visitChildren(node);
html.close(tag);
table.done(node);
}
@Override
public void visit(TableColumnNode node) {
// Not for output; should not be in the Visitor API.
}
@Override
public void visit(TextNode node) {
html.appendAndEscape(node.getText());
// TODO(sop) printWithAbbreviations
}
@Override
public void visit(SpecialTextNode node) {
html.appendAndEscape(node.getText());
}
@Override
public void visit(QuotedNode node) {
switch (node.getType()) {
case DoubleAngle:
html.entity("&laquo;");
visitChildren(node);
html.entity("&raquo;");
break;
case Double:
html.entity("&ldquo;");
visitChildren(node);
html.entity("&rdquo;");
break;
case Single:
html.entity("&lsquo;");
visitChildren(node);
html.entity("&rsquo;");
break;
default:
checkState(false, "unsupported quote %s", node.getType());
}
}
@Override
public void visit(SimpleNode node) {
switch (node.getType()) {
case Apostrophe:
html.entity("&rsquo;");
break;
case Ellipsis:
html.entity("&hellip;");
break;
case Emdash:
html.entity("&mdash;");
break;
case Endash:
html.entity("&ndash;");
break;
case HRule:
html.open("hr");
break;
case Linebreak:
html.open("br");
break;
case Nbsp:
html.entity("&nbsp;");
break;
default:
checkState(false, "unsupported node %s", node.getType());
}
}
@Override
public void visit(SuperNode node) {
visitChildren(node);
}
@Override
public void visit(Node node) {
checkState(false, "node %s unsupported", node.getClass());
}
@Override
public void visit(HtmlBlockNode node) {
// Drop all HTML nodes.
}
@Override
public void visit(InlineHtmlNode node) {
// Drop all HTML nodes.
}
@Override
public void visit(ReferenceNode node) {
// Reference nodes are not printed; they only declare an item.
}
@Override
public void visit(AbbreviationNode node) {
// Abbreviation nodes are not printed; they only declare an item.
}
private void wrapText(String tag, TextNode node) {
html.open(tag).appendAndEscape(node.getText()).close(tag);
}
private void wrapChildren(String tag, SuperNode node) {
html.open(tag);
visitChildren(node);
html.close(tag);
}
private void visitChildren(Node node) {
for (Node child : node.getChildren()) {
child.accept(this);
}
}
}