package org.apache.velocity.tools.view; | |
/* | |
* Licensed to the Apache Software Foundation (ASF) under one | |
* or more contributor license agreements. See the NOTICE file | |
* distributed with this work for additional information | |
* regarding copyright ownership. The ASF licenses this file | |
* to you 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. | |
*/ | |
import java.io.InputStream; | |
import java.io.IOException; | |
import java.net.URL; | |
import java.util.ArrayList; | |
import java.util.Collections; | |
import java.util.LinkedHashMap; | |
import java.util.List; | |
import java.util.Map; | |
import javax.servlet.ServletContext; | |
import javax.servlet.http.HttpServletRequest; | |
import org.xml.sax.Attributes; | |
import org.xml.sax.SAXException; | |
import org.apache.commons.digester.Digester; | |
import org.apache.commons.digester.Rule; | |
import org.apache.velocity.tools.ClassUtils; | |
import org.apache.velocity.tools.view.ViewContext; | |
import org.apache.velocity.runtime.log.Log; | |
import org.apache.velocity.tools.Scope; | |
import org.apache.velocity.tools.ToolContext; | |
import org.apache.velocity.tools.config.DefaultKey; | |
import org.apache.velocity.tools.config.ValidScope; | |
/** | |
* <b>NOTE: This tool is considered "beta" quality due to lack of public testing | |
* and is not automatically provided via the default tools.xml file. | |
* </b> | |
* | |
* Tool to make it easier to manage usage of client-side dependencies. | |
* This is essentially a simple dependency system for javascript and css. | |
* This could be cleaned up to use fewer maps, use more classes, | |
* and cache formatted values, but this is good enough for now. | |
* | |
* To use it, create a ui.xml file at the root of the classpath. | |
* Follow the example below. By default, it prepends the request context path | |
* and then "css/" to every stylesheet file and the request context path | |
* and "js/" to every javascript file path. You can | |
* alter those defaults by changing the type definition. In the example | |
* below, the file path for the style type is changed to "/styles/", leaving out | |
* the {context}. | |
* | |
* This is safe in request scope, but the group info (from ui.xml) | |
* should only be read once. It is not re-parsed on every request. | |
* <p> | |
* Example of use: | |
* <pre> | |
* Template | |
* --- | |
* <html> | |
* <head> | |
* $depends.on('profile').print(' | |
* ') | |
* </head> | |
* ... | |
* | |
* Output | |
* ------ | |
* <html> | |
* <head> | |
* <style rel="stylesheet" type="text/css" href="css/globals.css"/> | |
* <script type="text/javascript" src="js/jquery.js"></script> | |
* <script type="text/javascript" src="js/profile.js"></script> | |
* </head> | |
* ... | |
* | |
* Example tools.xml: | |
* <tools> | |
* <toolbox scope="request"> | |
* <tool class="org.apache.velocity.tools.view.beta.UiDependencyTool"/> | |
* </toolbox> | |
* </tools> | |
* | |
* Example ui.xml: | |
* <ui> | |
* <type name="style"><![CDATA[<link rel="stylesheet" type="text/css" href="/styles/{file}">]]></type> | |
* <group name="globals"> | |
* <file type="style">css/globals.css<file/> | |
* </group> | |
* <group name="jquery"> | |
* <file type="script">js/jquery.js<file/> | |
* </group> | |
* <group name="profile"> | |
* <needs>globals</needs> | |
* <needs>jquery</needs> | |
* <file type="script">js/profile.js<file/> | |
* </group> | |
* </ui> | |
* </pre> | |
* </p> | |
* | |
* @author Nathan Bubna | |
* @version $Revision: 16660 $ | |
*/ | |
@DefaultKey("depends") | |
@ValidScope(Scope.REQUEST) | |
public class UiDependencyTool { | |
public static final String GROUPS_KEY_SPACE = UiDependencyTool.class.getName() + ":"; | |
public static final String TYPES_KEY_SPACE = UiDependencyTool.class.getName() + ":types:"; | |
public static final String SOURCE_FILE_KEY = "file"; | |
public static final String DEFAULT_SOURCE_FILE = "ui.xml"; | |
private static final List<Type> DEFAULT_TYPES; | |
static { | |
List<Type> types = new ArrayList<Type>(); | |
// start out with these two types | |
types.add(new Type("style", "<link rel=\"stylesheet\" type=\"text/css\" href=\"{context}/css/{file}\"/>")); | |
types.add(new Type("script", "<script type=\"text/javascript\" src=\"{context}/js/{file}\"></script>")); | |
DEFAULT_TYPES = Collections.unmodifiableList(types); | |
} | |
private Map<String,Group> groups = null; | |
private List<Type> types = DEFAULT_TYPES; | |
private Map<String,List<String>> dependencies; | |
private Log LOG; | |
private String context = ""; | |
private void debug(String msg, Object... args) { | |
if (LOG.isDebugEnabled()) { | |
LOG.debug(String.format("UiDependencyTool: "+msg, args)); | |
} | |
} | |
protected static final void trace(Log log, String msg, Object... args) { | |
if (log.isTraceEnabled()) { | |
log.trace(String.format("UiDependencyTool: "+msg, args)); | |
} | |
} | |
public void configure(Map params) { | |
ServletContext app = (ServletContext)params.get(ViewContext.SERVLET_CONTEXT_KEY); | |
LOG = (Log)params.get(ToolContext.LOG_KEY); | |
HttpServletRequest request = (HttpServletRequest)params.get(ViewContext.REQUEST); | |
context = request.getContextPath(); | |
String file = (String)params.get(SOURCE_FILE_KEY); | |
if (file == null) { | |
file = DEFAULT_SOURCE_FILE; | |
} else { | |
debug("Loading file: %s", file); | |
} | |
synchronized (app) { | |
// first, see if we've already read this file | |
groups = (Map<String,Group>)app.getAttribute(GROUPS_KEY_SPACE+file); | |
if (groups == null) { | |
groups = new LinkedHashMap<String,Group>(); | |
// only require file presence, if one is specified | |
read(file, (file != DEFAULT_SOURCE_FILE)); | |
app.setAttribute(GROUPS_KEY_SPACE+file, groups); | |
if (types != DEFAULT_TYPES) { | |
app.setAttribute(TYPES_KEY_SPACE+file, types); | |
} | |
} else { | |
// load any custom types too | |
List<Type> alt = (List<Type>)app.getAttribute(TYPES_KEY_SPACE+file); | |
if (alt != null) { | |
types = alt; | |
} | |
} | |
} | |
} | |
/** | |
* Adds all the files required for the specified group, then returns | |
* this instance. If the group name is null or no such group exists, | |
* this will return null to indicate the error. | |
*/ | |
public UiDependencyTool on(String name) { | |
Map<String,List<String>> groupDeps = getGroupDependencies(name); | |
if (groupDeps == null) { | |
return null; | |
} else { | |
addDependencies(groupDeps); | |
return this; | |
} | |
} | |
/** | |
* Adds the specified file to this instance's list of dependencies | |
* of the specified type, then returns this instance. If either the | |
* type or file are null, this will return null to indicate the error. | |
*/ | |
public UiDependencyTool on(String type, String file) { | |
if (type == null || file == null) { | |
return null; | |
} else { | |
addFile(type, file); | |
return this; | |
} | |
} | |
/** | |
* Formats and prints all the current dependencies of this tool, | |
* using a new line in between the printed/formatted files. | |
*/ | |
public String print() { | |
return printAll("\n"); | |
} | |
/** | |
* If the parameter value is a known type, then this will | |
* format and print all of this instance's current dependencies of the | |
* specified type, using a new line in between the printed/formatted files. | |
* If the parameter value is NOT a known type, then this will treat it | |
* as a delimiter and print all of this instance's dependencies of all | |
* types, using the specified value as the delimiter in between the | |
* printed/formatted files. | |
* @see #print(String,String) | |
* @see #printAll(String) | |
*/ | |
public String print(String typeOrDelim) { | |
if (getType(typeOrDelim) == null) { | |
// then it's a delimiter | |
return printAll(typeOrDelim); | |
} else { | |
// then it's obviously a type | |
return print(typeOrDelim, "\n"); | |
} | |
} | |
/** | |
* Formats and prints all of this instance's current dependencies of the | |
* specified type, using the specified delimiter in between the | |
* printed/formatted files. | |
*/ | |
public String print(String type, String delim) { | |
List<String> files = getDependencies(type); | |
if (files == null) { | |
return null; | |
} | |
String format = getFormat(type); | |
StringBuilder out = new StringBuilder(); | |
for (String file : files) { | |
out.append(format(format, file)); | |
out.append(delim); | |
} | |
return out.toString(); | |
} | |
/** | |
* Formats and prints all the current dependencies of this tool, | |
* using the specified delimiter in between the printed/formatted files. | |
*/ | |
public String printAll(String delim) { | |
if (dependencies == null) { | |
return null; | |
} | |
StringBuilder out = new StringBuilder(); | |
for (Type type : types) { | |
if (out.length() > 0) { | |
out.append(delim); | |
} | |
List<String> files = dependencies.get(type.name); | |
if (files != null) { | |
for (int i=0; i < files.size(); i++) { | |
if (i > 0) { | |
out.append(delim); | |
} | |
out.append(format(type.format, files.get(i))); | |
} | |
} | |
} | |
return out.toString(); | |
} | |
/** | |
* Sets a custom {context} variable for the formats to use. | |
*/ | |
public UiDependencyTool context(String path) | |
{ | |
this.context = path; | |
return this; | |
} | |
/** | |
* Retrieves the configured format string for the specified file type. | |
*/ | |
public String getFormat(String type) { | |
Type t = getType(type); | |
if (t == null) { | |
return null; | |
} | |
return t.format; | |
} | |
/** | |
* Sets the format string for the specified file type. | |
*/ | |
public void setFormat(String type, String format) { | |
if (format == null || type == null) { | |
throw new NullPointerException("Type name and format must not be null"); | |
} | |
// do NOT alter the defaults, just copy them | |
if (types == DEFAULT_TYPES) { | |
types = new ArrayList<Type>(); | |
for (Type t : DEFAULT_TYPES) { | |
types.add(new Type(t.name, t.format)); | |
} | |
} | |
Type t = getType(type); | |
if (t == null) { | |
types.add(new Type(type, format)); | |
} else { | |
t.format = format; | |
} | |
} | |
/** | |
* Returns the current dependencies of this instance, organized | |
* as an ordered map of file types to lists of the required files | |
* of that type. | |
*/ | |
public Map<String,List<String>> getDependencies() { | |
return dependencies; | |
} | |
/** | |
* Returns the {@link List} of files for the specified file type, if any. | |
*/ | |
public List<String> getDependencies(String type) { | |
if (dependencies == null) { | |
return null; | |
} | |
return dependencies.get(type); | |
} | |
/** | |
* Returns the dependencies of the specified group, organized | |
* as an ordered map of file types to lists of the required files | |
* of that type. | |
*/ | |
public Map<String,List<String>> getGroupDependencies(String name) { | |
Group group = getGroup(name); | |
if (group == null) { | |
return null; | |
} | |
return group.getDependencies(this); | |
} | |
/** | |
* Returns an empty String to avoid polluting the template output after a | |
* successful call to {@link #on(String)} or {@link #on(String,String)}. | |
*/ | |
@Override | |
public String toString() { | |
return ""; | |
} | |
/** | |
* Reads group info out of the specified file and into this instance. | |
* If the file cannot be found and required is true, then this will throw | |
* an IllegalArgumentException. Otherwise, it will simply do nothing. Any | |
* checked exceptions during the actual reading of the file are caught and | |
* wrapped as {@link RuntimeException}s. | |
*/ | |
protected void read(String file, boolean required) { | |
debug("UiDependencyTool: Reading file from %s", file); | |
URL url = toURL(file); | |
if (url == null) { | |
String msg = "UiDependencyTool: Could not read file from '"+file+"'"; | |
if (required) { | |
LOG.error(msg); | |
throw new IllegalArgumentException(msg); | |
} else { | |
LOG.debug(msg); | |
} | |
} else { | |
Digester digester = createDigester(); | |
try | |
{ | |
digester.parse(url.openStream()); | |
} | |
catch (SAXException saxe) | |
{ | |
LOG.error("UiDependencyTool: Failed to parse '"+file+"'", saxe); | |
throw new RuntimeException("While parsing the InputStream", saxe); | |
} | |
catch (IOException ioe) | |
{ | |
LOG.error("UiDependencyTool: Failed to read '"+file+"'", ioe); | |
throw new RuntimeException("While handling the InputStream", ioe); | |
} | |
} | |
} | |
/** | |
* Creates the {@link Digester} used by {@link #read} to create | |
* the group info for this instance out of the specified XML file. | |
*/ | |
protected Digester createDigester() { | |
Digester digester = new Digester(); | |
digester.setValidating(false); | |
digester.setUseContextClassLoader(true); | |
digester.addRule("ui/type", new TypeRule()); | |
digester.addRule("ui/group", new GroupRule()); | |
digester.addRule("ui/group/file", new FileRule()); | |
digester.addRule("ui/group/needs", new NeedsRule()); | |
digester.push(this); | |
return digester; | |
} | |
/** | |
* Applies the format string to the given value. Currently, | |
* this simply replaces '{file}' with the value. If you | |
* want to handle more complicated formats, override this method. | |
*/ | |
protected String format(String format, String value) { | |
if (format == null) { | |
return value; | |
} | |
return format.replace("{file}", value).replace("{context}", this.context); | |
} | |
/** | |
* NOTE: This method may change or disappear w/o warning; don't depend | |
* on it unless you're willing to update your code whenever this changes. | |
*/ | |
protected Group getGroup(String name) { | |
if (groups == null) { | |
return null; | |
} | |
return groups.get(name); | |
} | |
/** | |
* NOTE: This method may change or disappear w/o warning; don't depend | |
* on it unless you're willing to update your code whenever this changes. | |
*/ | |
protected Group makeGroup(String name) { | |
trace(LOG, "Creating group '%s'", name); | |
Group group = new Group(name, LOG); | |
groups.put(name, group); | |
return group; | |
} | |
/** | |
* Adds the specified files organized by type to this instance's | |
* current dependencies. | |
*/ | |
protected void addDependencies(Map<String,List<String>> fbt) { | |
if (this.dependencies == null) { | |
dependencies = new LinkedHashMap<String,List<String>>(fbt.size()); | |
} | |
for (Map.Entry<String,List<String>> entry : fbt.entrySet()) { | |
String type = entry.getKey(); | |
if (getType(type) == null) { | |
LOG.error("UiDependencyTool: Type '"+type+"' is unknown and will not be printed unless defined."); | |
} | |
List<String> existing = dependencies.get(type); | |
if (existing == null) { | |
existing = new ArrayList<String>(entry.getValue().size()); | |
dependencies.put(type, existing); | |
} | |
for (String file : entry.getValue()) { | |
if (!existing.contains(file)) { | |
trace(LOG, "Adding %s: %s", type, file); | |
existing.add(file); | |
} | |
} | |
} | |
} | |
/** | |
* Adds a file to this instance's dependencies under the specified type. | |
*/ | |
protected void addFile(String type, String file) { | |
List<String> files = null; | |
if (dependencies == null) { | |
dependencies = new LinkedHashMap<String,List<String>>(types.size()); | |
} else { | |
files = dependencies.get(type); | |
} | |
if (files == null) { | |
files = new ArrayList<String>(); | |
dependencies.put(type, files); | |
} | |
if (!files.contains(file)) { | |
trace(LOG, "Adding %s: %s", type, file); | |
files.add(file); | |
} | |
} | |
/** | |
* For internal use only. Use/override get/setFormat instead. | |
*/ | |
private Type getType(String type) { | |
for (Type t : types) { | |
if (t.name.equals(type)) { | |
return t; | |
} | |
} | |
return null; | |
} | |
//TODO: replace this method with ConversionUtils.toURL(file, this) | |
// once VelocityTools 2.0-beta3 or 2.0 final is released. | |
private URL toURL(String file) { | |
try | |
{ | |
return ClassUtils.getResource(file, this); | |
} | |
catch (Exception e) { | |
return null; | |
} | |
} | |
/** | |
* NOTE: This class may change or disappear w/o warning; don't depend | |
* on it unless you're willing to update your code whenever this changes. | |
*/ | |
protected static class Group { | |
private volatile boolean resolved = true; | |
private String name; | |
private Map<String,Integer> typeCounts = new LinkedHashMap<String,Integer>(); | |
private Map<String,List<String>> dependencies = new LinkedHashMap<String,List<String>>(); | |
private List<String> groups; | |
private Log LOG; | |
public Group(String name, Log log) { | |
this.name = name; | |
this.LOG = log; | |
} | |
private void trace(String msg, Object... args) { | |
if (LOG.isTraceEnabled()) { | |
UiDependencyTool.trace(LOG, "Group "+name+": "+msg, args); | |
} | |
} | |
public void addFile(String type, String value) { | |
List<String> files = dependencies.get(type); | |
if (files == null) { | |
files = new ArrayList<String>(); | |
dependencies.put(type, files); | |
} | |
if (!files.contains(value)) { | |
trace("Adding %s: %s", type, value); | |
files.add(value); | |
} | |
} | |
public void addGroup(String group) { | |
if (this.groups == null) { | |
this.resolved = false; | |
this.groups = new ArrayList<String>(); | |
} | |
if (!this.groups.contains(group)) { | |
trace("Adding group %s", group, name); | |
this.groups.add(group); | |
} | |
} | |
public Map<String,List<String>> getDependencies(UiDependencyTool parent) { | |
resolve(parent); | |
return this.dependencies; | |
} | |
protected void resolve(UiDependencyTool parent) { | |
if (!resolved) { | |
// mark first to keep circular from becoming infinite | |
resolved = true; | |
trace("resolving..."); | |
for (String name : groups) { | |
Group group = parent.getGroup(name); | |
if (group == null) { | |
throw new NullPointerException("No group named '"+name+"'"); | |
} | |
Map<String,List<String>> dependencies = group.getDependencies(parent); | |
for (Map.Entry<String,List<String>> type : dependencies.entrySet()) { | |
for (String value : type.getValue()) { | |
addFileFromGroup(type.getKey(), value); | |
} | |
} | |
} | |
trace(" is resolved."); | |
} | |
} | |
private void addFileFromGroup(String type, String value) { | |
List<String> files = dependencies.get(type); | |
if (files == null) { | |
files = new ArrayList<String>(); | |
files.add(value); | |
trace("adding %s '%s' first", type, value); | |
dependencies.put(type, files); | |
typeCounts.put(type, 1); | |
} else if (!files.contains(value)) { | |
Integer count = typeCounts.get(type); | |
if (count == null) { | |
count = 0; | |
} | |
files.add(count, value); | |
trace("adding %s '%s' at %s", type, value, count); | |
typeCounts.put(type, ++count); | |
} | |
} | |
} | |
/** | |
* NOTE: This class may change or disappear w/o warning; don't depend | |
* on it unless you're willing to update your code whenever this changes. | |
*/ | |
protected static class TypeRule extends Rule { | |
private UiDependencyTool parent; | |
public void begin(String ns, String el, Attributes attributes) throws Exception { | |
parent = (UiDependencyTool)digester.peek(); | |
for (int i=0; i < attributes.getLength(); i++) { | |
String name = attributes.getLocalName(i); | |
if ("".equals(name)) { | |
name = attributes.getQName(i); | |
} | |
if ("name".equals(name)) { | |
digester.push(attributes.getValue(i)); | |
} | |
} | |
} | |
public void body(String ns, String el, String typeFormat) throws Exception { | |
String typeName = (String)digester.pop(); | |
parent.setFormat(typeName, typeFormat); | |
} | |
} | |
/** | |
* NOTE: This class may change or disappear w/o warning; don't depend | |
* on it unless you're willing to update your code whenever this changes. | |
*/ | |
protected static class GroupRule extends Rule { | |
private UiDependencyTool parent; | |
public void begin(String ns, String el, Attributes attributes) throws Exception { | |
parent = (UiDependencyTool)digester.peek(); | |
for (int i=0; i < attributes.getLength(); i++) { | |
String name = attributes.getLocalName(i); | |
if ("".equals(name)) { | |
name = attributes.getQName(i); | |
} | |
if ("name".equals(name)) { | |
digester.push(parent.makeGroup(attributes.getValue(i))); | |
} | |
} | |
} | |
public void end(String ns, String el) throws Exception { | |
digester.pop(); | |
} | |
} | |
/** | |
* NOTE: This class may change or disappear w/o warning; don't depend | |
* on it unless you're willing to update your code whenever this changes. | |
*/ | |
protected static class FileRule extends Rule { | |
public void begin(String ns, String el, Attributes attributes) throws Exception { | |
for (int i=0; i < attributes.getLength(); i++) { | |
String name = attributes.getLocalName(i); | |
if ("".equals(name)) { | |
name = attributes.getQName(i); | |
} | |
if ("type".equals(name)) { | |
digester.push(attributes.getValue(i)); | |
} | |
} | |
} | |
public void body(String ns, String el, String value) throws Exception { | |
String type = (String)digester.pop(); | |
Group group = (Group)digester.peek(); | |
group.addFile(type, value); | |
} | |
} | |
/** | |
* NOTE: This class may change or disappear w/o warning; don't depend | |
* on it unless you're willing to update your code whenever this changes. | |
*/ | |
protected static class NeedsRule extends Rule { | |
public void body(String ns, String el, String otherGroup) throws Exception { | |
Group group = (Group)digester.peek(); | |
group.addGroup(otherGroup); | |
} | |
} | |
private static final class Type { | |
protected String name; | |
protected String format; | |
Type(String n, String f) { | |
name = n; | |
format = f; | |
} | |
} | |
} |