| // 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("«"); |
| visitChildren(node); |
| html.entity("»"); |
| break; |
| case Double: |
| html.entity("“"); |
| visitChildren(node); |
| html.entity("”"); |
| break; |
| case Single: |
| html.entity("‘"); |
| visitChildren(node); |
| html.entity("’"); |
| break; |
| default: |
| checkState(false, "unsupported quote %s", node.getType()); |
| } |
| } |
| |
| @Override |
| public void visit(SimpleNode node) { |
| switch (node.getType()) { |
| case Apostrophe: |
| html.entity("’"); |
| break; |
| case Ellipsis: |
| html.entity("…"); |
| break; |
| case Emdash: |
| html.entity("—"); |
| break; |
| case Endash: |
| html.entity("–"); |
| break; |
| case HRule: |
| html.open("hr"); |
| break; |
| case Linebreak: |
| html.open("br"); |
| break; |
| case Nbsp: |
| html.entity(" "); |
| 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); |
| } |
| } |
| } |