Markdown formatting improvements
Extract the first H1 level string from the documentation and make that the HTML page title. Embed a small block of CSS that makes the style of the page look more like the rest of the Gerrit documentation pages. Wrap the entire pegdown output in a proper HTML tag. The pegdown.css is cached when read from a JAR, but loads on demand when read from a file:// URL. This enables a fast edit-view cycle of the style sheet when running the server from within Eclipse or another IDE that loads the resources from the source tree. If the Documentation/ URL ends with / redirect to look for index.html instead. This provides some simple support for directory indexes by allowing directory references to jump to the index.html document, which might be index.md in the JAR. If the plugin doesn't answer to / on its own, redirect web requests for /plugins/NAME to /plugins/NAME/Documentation/index.html allowing the user to at least see the documentation. Only attempt to convert resources that end with ".md" from Markdown to HTML, rather than everything. Documentation and static resources are only available by GET and HEAD HTTP methods. POST, PUT, DELETE, etc. will fail with an error. If there is no index.html or index.md in Documentation/, render one on the fly by creating a listing of documentation pages, extracting their titles and rendering it using the MarkdownFormatter. This keeps the style consistent with the rest of the documentation in a plugin. When rendering Documentation files from Markdown to HTML, replace the macro @PLUGIN@ with the current name of the plugin. This allows authors to write documentation with reasonably accurate examples that adjusts automatically based on the installation. Also support @URL@, @SSH_HOST@, and @SSH_PORT@ replacements in Markdown documentation for common ways to reference this server. Macros that start with \ such as \@KEEP@ will render as @KEEP@ even if there is an expansion for KEEP in the future. Formatted documentation and other static resources for plugins are cached in memory, to save response time when sending frequently requested items to clients. Change-Id: I988038021d4a047bd008561aeedc3ccfd5a1b294
This commit is contained in:
@@ -598,6 +598,13 @@ configuration.
|
||||
+
|
||||
Default is true, enabled.
|
||||
|
||||
cache.plugin_resources.memoryLimit::
|
||||
+
|
||||
Number of bytes of memory to use to cache formatted plugin resources,
|
||||
such as plugin documentation that has been converted from Markdown to
|
||||
HTML. Default is 2 MiB. Common unit suffixes of 'k', 'm', or 'g' are
|
||||
supported.
|
||||
|
||||
cache.projects.checkFrequency::
|
||||
+
|
||||
How often project configuration should be checked for update from Git.
|
||||
|
||||
@@ -255,6 +255,46 @@ link:http://daringfireball.net/projects/markdown/[Markdown] style
|
||||
if the file name ends with `.md`. Gerrit will automatically convert
|
||||
Markdown to HTML if accessed with extension `.html`.
|
||||
|
||||
Automatic Index
|
||||
~~~~~~~~~~~~~~~
|
||||
|
||||
If a plugin does not handle its `/` URL itself, Gerrit will
|
||||
redirect clients to the plugin's `/Documentation/index.html`.
|
||||
Requests for `/Documentation/` (bare directory) will also redirect
|
||||
to `/Documentation/index.html`.
|
||||
|
||||
If neither resource `Documentation/index.html` or
|
||||
`Documentation/index.md` exists in the plugin JAR, Gerrit will
|
||||
automatically generate an index page for the plugin's documentation
|
||||
tree by scanning every `*.md` and `*.html` file in the Documentation/
|
||||
directory.
|
||||
|
||||
For any discovered Markdown (`*.md`) file, Gerrit will parse the
|
||||
header of the file and extract the first level one title. This
|
||||
title text will be used as display text for a link to the HTML
|
||||
version of the page.
|
||||
|
||||
For any discovered HTML (`*.html`) file, Gerrit will use the name
|
||||
of the file, minus the `*.html` extension, as the link text. Any
|
||||
hyphens in the file name will be replaced with spaces.
|
||||
|
||||
If a discovered file name beings with `cmd-` it will be clustered
|
||||
into a 'Commands' section of the generated index page. All other
|
||||
files are clustered under a 'Documentation' section.
|
||||
|
||||
Some optional information from the manifest is extracted and
|
||||
displayed as part of the index page, if present in the manifest:
|
||||
|
||||
[width="40%",options="header"]
|
||||
|===================================================
|
||||
|Field | Source Attribute
|
||||
|Name | Implementation-Title
|
||||
|Vendor | Implementation-Vendor
|
||||
|Version | Implementation-Version
|
||||
|URL | Implementation-URL
|
||||
|API Version | Gerrit-ApiVersion
|
||||
|===================================================
|
||||
|
||||
Deployment
|
||||
----------
|
||||
|
||||
|
||||
@@ -15,33 +15,49 @@
|
||||
package com.google.gerrit.httpd.plugins;
|
||||
|
||||
import com.google.common.base.Strings;
|
||||
import com.google.common.cache.Cache;
|
||||
import com.google.common.cache.CacheBuilder;
|
||||
import com.google.common.cache.Weigher;
|
||||
import com.google.common.collect.Lists;
|
||||
import com.google.common.collect.Maps;
|
||||
import com.google.gerrit.extensions.registration.RegistrationHandle;
|
||||
import com.google.gerrit.server.documentation.MarkdownFormatter;
|
||||
import com.google.gerrit.server.MimeUtilFileTypeRegistry;
|
||||
import com.google.gerrit.server.config.CanonicalWebUrl;
|
||||
import com.google.gerrit.server.config.GerritServerConfig;
|
||||
import com.google.gerrit.server.documentation.MarkdownFormatter;
|
||||
import com.google.gerrit.server.plugins.Plugin;
|
||||
import com.google.gerrit.server.plugins.ReloadPluginListener;
|
||||
import com.google.gerrit.server.plugins.StartPluginListener;
|
||||
import com.google.gerrit.server.ssh.SshInfo;
|
||||
import com.google.inject.Inject;
|
||||
import com.google.inject.Provider;
|
||||
import com.google.inject.Singleton;
|
||||
import com.google.inject.servlet.GuiceFilter;
|
||||
|
||||
import eu.medsea.mimeutil.MimeType;
|
||||
|
||||
import org.eclipse.jgit.lib.Config;
|
||||
import org.eclipse.jgit.util.IO;
|
||||
import org.eclipse.jgit.util.RawParseUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.Enumeration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.jar.Attributes;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarFile;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import javax.servlet.FilterChain;
|
||||
import javax.servlet.ServletConfig;
|
||||
import javax.servlet.ServletException;
|
||||
@@ -55,19 +71,57 @@ import javax.servlet.http.HttpServletResponse;
|
||||
@Singleton
|
||||
class HttpPluginServlet extends HttpServlet
|
||||
implements StartPluginListener, ReloadPluginListener {
|
||||
private static final int SMALL_RESOURCE = 128 * 1024;
|
||||
private static final long serialVersionUID = 1L;
|
||||
private static final Logger log
|
||||
= LoggerFactory.getLogger(HttpPluginServlet.class);
|
||||
|
||||
private final MimeUtilFileTypeRegistry mimeUtil;
|
||||
private final Provider<String> webUrl;
|
||||
private final Cache<ResourceKey, Resource> resourceCache;
|
||||
private final String sshHost;
|
||||
private final int sshPort;
|
||||
|
||||
private List<Plugin> pending = Lists.newArrayList();
|
||||
private String base;
|
||||
private final ConcurrentMap<String, PluginHolder> plugins
|
||||
= Maps.newConcurrentMap();
|
||||
|
||||
@Inject
|
||||
HttpPluginServlet(MimeUtilFileTypeRegistry mimeUtil) {
|
||||
HttpPluginServlet(MimeUtilFileTypeRegistry mimeUtil,
|
||||
@CanonicalWebUrl Provider<String> webUrl,
|
||||
@GerritServerConfig Config cfg,
|
||||
SshInfo sshInfo) {
|
||||
this.mimeUtil = mimeUtil;
|
||||
this.webUrl = webUrl;
|
||||
|
||||
this.resourceCache = CacheBuilder.newBuilder()
|
||||
.maximumWeight(cfg.getInt(
|
||||
"cache", "plugin_resources", "memoryLimit",
|
||||
2 * 1024 * 1024))
|
||||
.weigher(new Weigher<ResourceKey, Resource>() {
|
||||
@Override
|
||||
public int weigh(ResourceKey key, Resource value) {
|
||||
return key.weight() + value.weight();
|
||||
}
|
||||
})
|
||||
.build();
|
||||
|
||||
String sshHost = "review.example.com";
|
||||
int sshPort = 29418;
|
||||
if (!sshInfo.getHostKeys().isEmpty()) {
|
||||
String host = sshInfo.getHostKeys().get(0).getHost();
|
||||
int c = host.lastIndexOf(':');
|
||||
if (0 <= c) {
|
||||
sshHost = host.substring(0, c);
|
||||
sshPort = Integer.parseInt(host.substring(c+1));
|
||||
} else {
|
||||
sshHost = host;
|
||||
sshPort = 22;
|
||||
}
|
||||
}
|
||||
this.sshHost = sshHost;
|
||||
this.sshPort = sshPort;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -168,59 +222,275 @@ class HttpPluginServlet extends HttpServlet
|
||||
private void onDefault(PluginHolder holder,
|
||||
HttpServletRequest req,
|
||||
HttpServletResponse res) throws IOException {
|
||||
if (!"GET".equals(req.getMethod()) && !"HEAD".equals(req.getMethod())) {
|
||||
noCache(res);
|
||||
res.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
|
||||
return;
|
||||
}
|
||||
|
||||
String uri = req.getRequestURI();
|
||||
String ctx = req.getContextPath();
|
||||
String file = uri.substring(ctx.length() + 1);
|
||||
if (file.startsWith("Documentation/") || file.startsWith("static/")) {
|
||||
|
||||
ResourceKey key = new ResourceKey(holder.plugin, file);
|
||||
Resource rsc = resourceCache.getIfPresent(key);
|
||||
if (rsc != null) {
|
||||
rsc.send(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
if ("".equals(file)) {
|
||||
res.sendRedirect(uri + "Documentation/index.html");
|
||||
} else if (file.startsWith("static/")) {
|
||||
JarFile jar = holder.plugin.getJarFile();
|
||||
JarEntry entry = jar.getJarEntry(file);
|
||||
if (file.startsWith("Documentation/") && !isValidEntry(entry)) {
|
||||
entry = getRealFileEntry(jar, file);
|
||||
if (isValidEntry(entry)) {
|
||||
sendResource(jar, entry, res, holder.plugin.getName(), true);
|
||||
return;
|
||||
if (exists(entry)) {
|
||||
sendResource(jar, entry, key, res);
|
||||
} else {
|
||||
resourceCache.put(key, NOT_FOUND);
|
||||
NOT_FOUND.send(req, res);
|
||||
}
|
||||
} else if (file.equals("Documentation")) {
|
||||
res.sendRedirect(uri + "/index.html");
|
||||
} else if (file.startsWith("Documentation/") && file.endsWith("/")) {
|
||||
res.sendRedirect(uri + "index.html");
|
||||
} else if (file.startsWith("Documentation/")) {
|
||||
JarFile jar = holder.plugin.getJarFile();
|
||||
JarEntry entry = jar.getJarEntry(file);
|
||||
if (!exists(entry)) {
|
||||
entry = findSource(jar, file);
|
||||
}
|
||||
if (isValidEntry(entry)) {
|
||||
sendResource(jar, entry, res, holder.plugin.getName());
|
||||
return;
|
||||
if (!exists(entry) && file.endsWith("/index.html")) {
|
||||
String pfx = file.substring(0, file.length() - "index.html".length());
|
||||
sendAutoIndex(jar, pfx, holder.plugin.getName(), key, res);
|
||||
} else if (exists(entry) && entry.getName().endsWith(".md")) {
|
||||
sendMarkdownAsHtml(jar, entry, holder.plugin.getName(), key, res);
|
||||
} else if (exists(entry)) {
|
||||
sendResource(jar, entry, key, res);
|
||||
} else {
|
||||
resourceCache.put(key, NOT_FOUND);
|
||||
NOT_FOUND.send(req, res);
|
||||
}
|
||||
} else {
|
||||
resourceCache.put(key, NOT_FOUND);
|
||||
NOT_FOUND.send(req, res);
|
||||
}
|
||||
}
|
||||
|
||||
noCache(res);
|
||||
res.sendError(HttpServletResponse.SC_NOT_FOUND);
|
||||
private void sendAutoIndex(JarFile jar,
|
||||
String prefix, String pluginName,
|
||||
ResourceKey cacheKey, HttpServletResponse res) throws IOException {
|
||||
List<JarEntry> cmds = Lists.newArrayList();
|
||||
List<JarEntry> docs = Lists.newArrayList();
|
||||
Enumeration<JarEntry> entries = jar.entries();
|
||||
while (entries.hasMoreElements()) {
|
||||
JarEntry entry = entries.nextElement();
|
||||
String name = entry.getName();
|
||||
long size = entry.getSize();
|
||||
if (name.startsWith(prefix)
|
||||
&& (name.endsWith(".md")
|
||||
|| name.endsWith(".html"))
|
||||
&& 0 < size && size <= SMALL_RESOURCE) {
|
||||
if (name.substring(prefix.length()).startsWith("cmd-")) {
|
||||
cmds.add(entry);
|
||||
} else {
|
||||
docs.add(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
Collections.sort(cmds, new Comparator<JarEntry>() {
|
||||
@Override
|
||||
public int compare(JarEntry a, JarEntry b) {
|
||||
return a.getName().compareTo(b.getName());
|
||||
}
|
||||
});
|
||||
Collections.sort(docs, new Comparator<JarEntry>() {
|
||||
@Override
|
||||
public int compare(JarEntry a, JarEntry b) {
|
||||
return a.getName().compareTo(b.getName());
|
||||
}
|
||||
});
|
||||
|
||||
StringBuilder md = new StringBuilder();
|
||||
md.append(String.format("# Plugin %s #\n", pluginName));
|
||||
md.append("\n");
|
||||
appendPluginInfoTable(md, jar.getManifest().getMainAttributes());
|
||||
|
||||
if (!docs.isEmpty()) {
|
||||
md.append("## Documentation ##\n");
|
||||
for(JarEntry entry : docs) {
|
||||
String rsrc = entry.getName().substring(prefix.length());
|
||||
String title;
|
||||
if (rsrc.endsWith(".html")) {
|
||||
title = rsrc.substring(0, rsrc.length() - 5).replace('-', ' ');
|
||||
} else if (rsrc.endsWith(".md")) {
|
||||
title = extractTitleFromMarkdown(jar, entry);
|
||||
if (Strings.isNullOrEmpty(title)) {
|
||||
title = rsrc.substring(0, rsrc.length() - 3).replace('-', ' ');
|
||||
}
|
||||
rsrc = rsrc.substring(0, rsrc.length() - 3) + ".html";
|
||||
} else {
|
||||
title = rsrc.replace('-', ' ');
|
||||
}
|
||||
md.append(String.format("* [%s](%s)\n", title, rsrc));
|
||||
}
|
||||
md.append("\n");
|
||||
}
|
||||
|
||||
private JarEntry getRealFileEntry(JarFile jar, String file) {
|
||||
// TODO: Replace with a loop iterating over possible formatters
|
||||
return jar.getJarEntry(file.replaceAll("\\.html$", ".md"));
|
||||
if (!cmds.isEmpty()) {
|
||||
md.append("## Commands ##\n");
|
||||
for(JarEntry entry : cmds) {
|
||||
String rsrc = entry.getName().substring(prefix.length());
|
||||
String title;
|
||||
if (rsrc.endsWith(".html")) {
|
||||
title = rsrc.substring(4, rsrc.length() - 5).replace('-', ' ');
|
||||
} else if (rsrc.endsWith(".md")) {
|
||||
title = extractTitleFromMarkdown(jar, entry);
|
||||
if (Strings.isNullOrEmpty(title)) {
|
||||
title = rsrc.substring(4, rsrc.length() - 3).replace('-', ' ');
|
||||
}
|
||||
rsrc = rsrc.substring(0, rsrc.length() - 3) + ".html";
|
||||
} else {
|
||||
title = rsrc.substring(4).replace('-', ' ');
|
||||
}
|
||||
md.append(String.format("* [%s](%s)\n", title, rsrc));
|
||||
}
|
||||
md.append("\n");
|
||||
}
|
||||
|
||||
private boolean isValidEntry(JarEntry entry) {
|
||||
sendMarkdownAsHtml(md.toString(), pluginName, cacheKey, res);
|
||||
}
|
||||
|
||||
private void sendMarkdownAsHtml(String md, String pluginName,
|
||||
ResourceKey cacheKey, HttpServletResponse res)
|
||||
throws UnsupportedEncodingException, IOException {
|
||||
Map<String, String> macros = Maps.newHashMap();
|
||||
macros.put("PLUGIN", pluginName);
|
||||
macros.put("SSH_HOST", sshHost);
|
||||
macros.put("SSH_PORT", "" + sshPort);
|
||||
String url = webUrl.get();
|
||||
if (Strings.isNullOrEmpty(url)) {
|
||||
url = "http://review.example.com/";
|
||||
}
|
||||
macros.put("URL", url);
|
||||
|
||||
Matcher m = Pattern.compile("(\\\\)?@([A-Z_]+)@").matcher(md);
|
||||
StringBuffer sb = new StringBuffer();
|
||||
while (m.find()) {
|
||||
String key = m.group(2);
|
||||
String val = macros.get(key);
|
||||
if (m.group(1) != null) {
|
||||
m.appendReplacement(sb, "@" + key + "@");
|
||||
} else if (val != null) {
|
||||
m.appendReplacement(sb, val);
|
||||
} else {
|
||||
m.appendReplacement(sb, "@" + key + "@");
|
||||
}
|
||||
}
|
||||
m.appendTail(sb);
|
||||
|
||||
byte[] html = new MarkdownFormatter()
|
||||
.markdownToDocHtml(sb.toString(), "UTF-8");
|
||||
resourceCache.put(cacheKey, new SmallResource(html)
|
||||
.setContentType("text/html")
|
||||
.setCharacterEncoding("UTF-8"));
|
||||
res.setContentType("text/html");
|
||||
res.setCharacterEncoding("UTF-8");
|
||||
res.setContentLength(html.length);
|
||||
res.getOutputStream().write(html);
|
||||
}
|
||||
|
||||
private static void appendPluginInfoTable(StringBuilder html, Attributes main) {
|
||||
if (main != null) {
|
||||
String t = main.getValue(Attributes.Name.IMPLEMENTATION_TITLE);
|
||||
String n = main.getValue(Attributes.Name.IMPLEMENTATION_VENDOR);
|
||||
String v = main.getValue(Attributes.Name.IMPLEMENTATION_VERSION);
|
||||
String u = main.getValue(Attributes.Name.IMPLEMENTATION_URL);
|
||||
String a = main.getValue("Gerrit-ApiVersion");
|
||||
|
||||
html.append("<table class=\"plugin_info\">");
|
||||
if (!Strings.isNullOrEmpty(t)) {
|
||||
html.append("<tr><th>Name</th><td>")
|
||||
.append(t)
|
||||
.append("</td></tr>\n");
|
||||
}
|
||||
if (!Strings.isNullOrEmpty(n)) {
|
||||
html.append("<tr><th>Vendor</th><td>")
|
||||
.append(n)
|
||||
.append("</td></tr>\n");
|
||||
}
|
||||
if (!Strings.isNullOrEmpty(v)) {
|
||||
html.append("<tr><th>Version</th><td>")
|
||||
.append(v)
|
||||
.append("</td></tr>\n");
|
||||
}
|
||||
if (!Strings.isNullOrEmpty(u)) {
|
||||
html.append("<tr><th>URL</th><td>")
|
||||
.append(String.format("<a href=\"%s\">%s</a>", u, u))
|
||||
.append("</td></tr>\n");
|
||||
}
|
||||
if (!Strings.isNullOrEmpty(a)) {
|
||||
html.append("<tr><th>API Version</th><td>")
|
||||
.append(a)
|
||||
.append("</td></tr>\n");
|
||||
}
|
||||
html.append("</table>\n");
|
||||
}
|
||||
}
|
||||
|
||||
private static String extractTitleFromMarkdown(JarFile jar, JarEntry entry)
|
||||
throws IOException {
|
||||
String charEnc = null;
|
||||
Attributes atts = entry.getAttributes();
|
||||
if (atts != null) {
|
||||
charEnc = Strings.emptyToNull(atts.getValue("Character-Encoding"));
|
||||
}
|
||||
if (charEnc == null) {
|
||||
charEnc = "UTF-8";
|
||||
}
|
||||
return new MarkdownFormatter().extractTitleFromMarkdown(
|
||||
readWholeEntry(jar, entry),
|
||||
charEnc);
|
||||
}
|
||||
|
||||
private static JarEntry findSource(JarFile jar, String file) {
|
||||
if (file.endsWith(".html")) {
|
||||
int d = file.lastIndexOf('.');
|
||||
return jar.getJarEntry(file.substring(0, d) + ".md");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean exists(JarEntry entry) {
|
||||
return entry != null && entry.getSize() > 0;
|
||||
}
|
||||
|
||||
private void sendResource(JarFile jar, JarEntry entry,
|
||||
HttpServletResponse res, String pluginName) throws IOException {
|
||||
sendResource(jar, entry, res, pluginName, false);
|
||||
private void sendMarkdownAsHtml(JarFile jar, JarEntry entry,
|
||||
String pluginName, ResourceKey key, HttpServletResponse res)
|
||||
throws IOException {
|
||||
byte[] rawmd = readWholeEntry(jar, entry);
|
||||
String encoding = null;
|
||||
Attributes atts = entry.getAttributes();
|
||||
if (atts != null) {
|
||||
encoding = Strings.emptyToNull(atts.getValue("Character-Encoding"));
|
||||
}
|
||||
|
||||
String txtmd = RawParseUtils.decode(
|
||||
Charset.forName(encoding != null ? encoding : "UTF-8"),
|
||||
rawmd);
|
||||
long time = entry.getTime();
|
||||
if (0 < time) {
|
||||
res.setDateHeader("Last-Modified", time);
|
||||
}
|
||||
sendMarkdownAsHtml(txtmd, pluginName, key, res);
|
||||
}
|
||||
|
||||
private void sendResource(JarFile jar, JarEntry entry,
|
||||
HttpServletResponse res, String pluginName, boolean format)
|
||||
ResourceKey key, HttpServletResponse res)
|
||||
throws IOException {
|
||||
String entryName = entry.getName();
|
||||
byte[] data = null;
|
||||
if (entry.getSize() <= 128 * 1024) {
|
||||
data = new byte[(int) entry.getSize()];
|
||||
InputStream in = jar.getInputStream(entry);
|
||||
try {
|
||||
IO.readFully(in, data, 0, data.length);
|
||||
} finally {
|
||||
in.close();
|
||||
}
|
||||
} else if (format == true) {
|
||||
log.warn(String.format("Plugin '%s' file '%s' too large to format",
|
||||
pluginName, entryName));
|
||||
if (entry.getSize() <= SMALL_RESOURCE) {
|
||||
data = readWholeEntry(jar, entry);
|
||||
}
|
||||
|
||||
String contentType = null;
|
||||
@@ -230,33 +500,24 @@ class HttpPluginServlet extends HttpServlet
|
||||
contentType = Strings.emptyToNull(atts.getValue("Content-Type"));
|
||||
charEnc = Strings.emptyToNull(atts.getValue("Character-Encoding"));
|
||||
}
|
||||
|
||||
if (contentType == null) {
|
||||
MimeType type = mimeUtil.getMimeType(entryName, data);
|
||||
contentType = type.toString();
|
||||
}
|
||||
|
||||
if (format && data != null) {
|
||||
if (charEnc == null) {
|
||||
charEnc = "UTF-8";
|
||||
}
|
||||
MarkdownFormatter fmter = new MarkdownFormatter();
|
||||
data = fmter.getHtmlFromMarkdown(data, charEnc);
|
||||
res.setHeader("Content-Length", Long.toString(data.length));
|
||||
contentType = "text/html";
|
||||
} else {
|
||||
res.setHeader("Content-Length", Long.toString(entry.getSize()));
|
||||
contentType = mimeUtil.getMimeType(entry.getName(), data).toString();
|
||||
}
|
||||
|
||||
long time = entry.getTime();
|
||||
if (0 < time) {
|
||||
res.setDateHeader("Last-Modified", time);
|
||||
}
|
||||
res.setHeader("Content-Length", Long.toString(entry.getSize()));
|
||||
res.setContentType(contentType);
|
||||
if (charEnc != null) {
|
||||
res.setCharacterEncoding(charEnc);
|
||||
}
|
||||
if (data != null) {
|
||||
resourceCache.put(key, new SmallResource(data)
|
||||
.setContentType(contentType)
|
||||
.setCharacterEncoding(charEnc)
|
||||
.setLastModified(time));
|
||||
res.getOutputStream().write(data);
|
||||
} else {
|
||||
InputStream in = jar.getInputStream(entry);
|
||||
@@ -277,6 +538,18 @@ class HttpPluginServlet extends HttpServlet
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] readWholeEntry(JarFile jar, JarEntry entry)
|
||||
throws IOException {
|
||||
byte[] data = new byte[(int) entry.getSize()];
|
||||
InputStream in = jar.getInputStream(entry);
|
||||
try {
|
||||
IO.readFully(in, data, 0, data.length);
|
||||
} finally {
|
||||
in.close();
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
private static String extractName(HttpServletRequest req) {
|
||||
String path = req.getPathInfo();
|
||||
if (Strings.isNullOrEmpty(path) || "/".equals(path)) {
|
||||
@@ -303,6 +576,99 @@ class HttpPluginServlet extends HttpServlet
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ResourceKey {
|
||||
private final Plugin.CacheKey plugin;
|
||||
private final String resource;
|
||||
|
||||
ResourceKey(Plugin p, String r) {
|
||||
this.plugin = p.getCacheKey();
|
||||
this.resource = r;
|
||||
}
|
||||
|
||||
int weight() {
|
||||
return 28 + resource.length();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return plugin.hashCode() * 31 + resource.hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (other instanceof ResourceKey) {
|
||||
ResourceKey rk = (ResourceKey) other;
|
||||
return plugin == rk.plugin && resource.equals(rk.resource);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static abstract class Resource {
|
||||
abstract int weight();
|
||||
abstract void send(HttpServletRequest req, HttpServletResponse res)
|
||||
throws IOException;
|
||||
}
|
||||
|
||||
private static final class SmallResource extends Resource {
|
||||
private final byte[] data;
|
||||
private String contentType;
|
||||
private String characterEncoding;
|
||||
private long lastModified;
|
||||
|
||||
SmallResource(byte[] data) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
SmallResource setLastModified(long when) {
|
||||
this.lastModified = when;
|
||||
return this;
|
||||
}
|
||||
|
||||
SmallResource setContentType(String contentType) {
|
||||
this.contentType = contentType;
|
||||
return this;
|
||||
}
|
||||
|
||||
SmallResource setCharacterEncoding(@Nullable String enc) {
|
||||
this.characterEncoding = enc;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
int weight() {
|
||||
return data.length;
|
||||
}
|
||||
|
||||
@Override
|
||||
void send(HttpServletRequest req, HttpServletResponse res)
|
||||
throws IOException {
|
||||
if (0 < lastModified) {
|
||||
res.setDateHeader("Last-Modified", lastModified);
|
||||
}
|
||||
res.setContentType(contentType);
|
||||
if (characterEncoding != null) {
|
||||
res.setCharacterEncoding(characterEncoding);
|
||||
}
|
||||
res.setContentLength(data.length);
|
||||
res.getOutputStream().write(data);
|
||||
}
|
||||
}
|
||||
|
||||
private static final Resource NOT_FOUND = new Resource() {
|
||||
@Override
|
||||
int weight() {
|
||||
return 4;
|
||||
}
|
||||
|
||||
@Override
|
||||
void send(HttpServletRequest req, HttpServletResponse res)
|
||||
throws IOException {
|
||||
noCache(res);
|
||||
res.sendError(HttpServletResponse.SC_NOT_FOUND);
|
||||
}
|
||||
};
|
||||
|
||||
private static class WrappedRequest extends HttpServletRequestWrapper {
|
||||
private final String contextPath;
|
||||
|
||||
|
||||
@@ -17,20 +17,129 @@ package com.google.gerrit.server.documentation;
|
||||
import static org.pegdown.Extensions.ALL;
|
||||
import static org.pegdown.Extensions.HARDWRAPS;
|
||||
|
||||
import org.eclipse.jgit.util.RawParseUtils;
|
||||
import org.pegdown.PegDownProcessor;
|
||||
import com.google.common.base.Strings;
|
||||
|
||||
import org.eclipse.jgit.util.RawParseUtils;
|
||||
import org.eclipse.jgit.util.TemporaryBuffer;
|
||||
import org.pegdown.LinkRenderer;
|
||||
import org.pegdown.PegDownProcessor;
|
||||
import org.pegdown.ToHtmlSerializer;
|
||||
import org.pegdown.ast.HeaderNode;
|
||||
import org.pegdown.ast.Node;
|
||||
import org.pegdown.ast.RootNode;
|
||||
import org.pegdown.ast.TextNode;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.Charset;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public class MarkdownFormatter {
|
||||
public byte[] getHtmlFromMarkdown(byte[] data, String charEnc)
|
||||
throws UnsupportedEncodingException {
|
||||
return new PegDownProcessor(ALL & ~(HARDWRAPS))
|
||||
.markdownToHtml(RawParseUtils.decode(
|
||||
Charset.forName(charEnc),
|
||||
data))
|
||||
.getBytes(charEnc);
|
||||
private static final Logger log =
|
||||
LoggerFactory.getLogger(MarkdownFormatter.class);
|
||||
|
||||
private static final String css;
|
||||
|
||||
static {
|
||||
AtomicBoolean file = new AtomicBoolean();
|
||||
String src;
|
||||
try {
|
||||
src = readPegdownCss(file);
|
||||
} catch (IOException err) {
|
||||
log.warn("Cannot load pegdown.css", err);
|
||||
src = "";
|
||||
}
|
||||
css = file.get() ? null : src;
|
||||
}
|
||||
|
||||
private static String readCSS() {
|
||||
if (css != null) {
|
||||
return css;
|
||||
}
|
||||
try {
|
||||
return readPegdownCss(new AtomicBoolean());
|
||||
} catch (IOException err) {
|
||||
log.warn("Cannot load pegdown.css", err);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] markdownToDocHtml(String md, String charEnc)
|
||||
throws UnsupportedEncodingException {
|
||||
RootNode root = parseMarkdown(md);
|
||||
String title = findTitle(root);
|
||||
|
||||
StringBuilder html = new StringBuilder();
|
||||
html.append("<html>");
|
||||
html.append("<head>");
|
||||
if (!Strings.isNullOrEmpty(title)) {
|
||||
html.append("<title>").append(title).append("</title>");
|
||||
}
|
||||
html.append("<style type=\"text/css\">\n")
|
||||
.append(readCSS())
|
||||
.append("\n</style>");
|
||||
html.append("</head>");
|
||||
html.append("<body>\n");
|
||||
html.append(new ToHtmlSerializer(new LinkRenderer()).toHtml(root));
|
||||
html.append("\n</body></html>");
|
||||
return html.toString().getBytes(charEnc);
|
||||
}
|
||||
|
||||
public String extractTitleFromMarkdown(byte[] data, String charEnc) {
|
||||
String md = RawParseUtils.decode(Charset.forName(charEnc), data);
|
||||
return findTitle(parseMarkdown(md));
|
||||
}
|
||||
|
||||
private String findTitle(Node root) {
|
||||
if (root instanceof HeaderNode) {
|
||||
HeaderNode h = (HeaderNode) root;
|
||||
if (h.getLevel() == 1
|
||||
&& h.getChildren() != null
|
||||
&& !h.getChildren().isEmpty()) {
|
||||
StringBuilder b = new StringBuilder();
|
||||
for (Node n : root.getChildren()) {
|
||||
if (n instanceof TextNode) {
|
||||
b.append(((TextNode) n).getText());
|
||||
}
|
||||
}
|
||||
return b.toString();
|
||||
}
|
||||
}
|
||||
|
||||
for (Node n : root.getChildren()) {
|
||||
String title = findTitle(n);
|
||||
if (title != null) {
|
||||
return title;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private RootNode parseMarkdown(String md) {
|
||||
return new PegDownProcessor(ALL & ~(HARDWRAPS))
|
||||
.parseMarkdown(md.toCharArray());
|
||||
}
|
||||
|
||||
private static String readPegdownCss(AtomicBoolean file)
|
||||
throws IOException {
|
||||
String name = "pegdown.css";
|
||||
URL url = MarkdownFormatter.class.getResource(name);
|
||||
if (url == null) {
|
||||
throw new FileNotFoundException("Resource " + name);
|
||||
}
|
||||
file.set("file".equals(url.getProtocol()));
|
||||
InputStream in = url.openStream();
|
||||
try {
|
||||
TemporaryBuffer.Heap tmp = new TemporaryBuffer.Heap(128 * 1024);
|
||||
tmp.copy(in);
|
||||
return new String(tmp.toByteArray(), "UTF-8");
|
||||
} finally {
|
||||
in.close();
|
||||
}
|
||||
}
|
||||
// TODO: Add a cache
|
||||
}
|
||||
|
||||
@@ -45,6 +45,21 @@ public class Plugin {
|
||||
EXTENSION, PLUGIN;
|
||||
}
|
||||
|
||||
/** Unique key that changes whenever a plugin reloads. */
|
||||
public static final class CacheKey {
|
||||
private final String name;
|
||||
|
||||
CacheKey(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
int id = System.identityHashCode(this);
|
||||
return String.format("Plugin[%s@%x]", name, id);
|
||||
}
|
||||
}
|
||||
|
||||
static {
|
||||
// Guice logs warnings about multiple injectors being created.
|
||||
// Silence this in case HTTP plugins are used.
|
||||
@@ -65,6 +80,7 @@ public class Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
private final CacheKey cacheKey;
|
||||
private final String name;
|
||||
private final File srcJar;
|
||||
private final FileSnapshot snapshot;
|
||||
@@ -94,6 +110,7 @@ public class Plugin {
|
||||
@Nullable Class<? extends Module> sysModule,
|
||||
@Nullable Class<? extends Module> sshModule,
|
||||
@Nullable Class<? extends Module> httpModule) {
|
||||
this.cacheKey = new CacheKey(name);
|
||||
this.name = name;
|
||||
this.srcJar = srcJar;
|
||||
this.snapshot = snapshot;
|
||||
@@ -111,6 +128,10 @@ public class Plugin {
|
||||
return srcJar;
|
||||
}
|
||||
|
||||
public CacheKey getCacheKey() {
|
||||
return cacheKey;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
body {
|
||||
margin: 1em;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: #527bbd;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
border-bottom: 2px solid silver;
|
||||
}
|
||||
|
||||
pre {
|
||||
border: 2px solid silver;
|
||||
background: #ebebeb;
|
||||
margin-left: 2em;
|
||||
width: 100em;
|
||||
color: darkgreen;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
dl dt {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
table.plugin_info {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
text-align: left;
|
||||
margin-left: 2em;
|
||||
}
|
||||
table.plugin_info th {
|
||||
padding-right: 0.5em;
|
||||
border-right: 2px solid silver;
|
||||
}
|
||||
table.plugin_info td {
|
||||
padding-left: 0.5em;
|
||||
}
|
||||
Reference in New Issue
Block a user