blob: 4bfe24d035ad0ff311c07f1d72af0b1477d083cd [file] [log] [blame]
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
* ---
* &lt;html&gt;
* &lt;head&gt;
* $depends.on('profile').print('
* ')
* &lt;/head&gt;
* ...
*
* Output
* ------
* &lt;html&gt;
* &lt;head&gt;
* &lt;style rel="stylesheet" type="text/css" href="css/globals.css"/&gt;
* &lt;script type="text/javascript" src="js/jquery.js"&gt;&lt;/script&gt;
* &lt;script type="text/javascript" src="js/profile.js"&gt;&lt;/script&gt;
* &lt;/head&gt;
* ...
*
* Example tools.xml:
* &lt;tools&gt;
* &lt;toolbox scope="request"&gt;
* &lt;tool class="org.apache.velocity.tools.view.beta.UiDependencyTool"/&gt;
* &lt;/toolbox&gt;
* &lt;/tools&gt;
*
* Example ui.xml:
* &lt;ui&gt;
* &lt;type name="style"&gt;&lt;![CDATA[&lt;link rel="stylesheet" type="text/css" href="/styles/{file}"&gt;]]&gt;&lt;/type&gt;
* &lt;group name="globals"&gt;
* &lt;file type="style"&gt;css/globals.css&lt;file/&gt;
* &lt;/group&gt;
* &lt;group name="jquery"&gt;
* &lt;file type="script"&gt;js/jquery.js&lt;file/&gt;
* &lt;/group&gt;
* &lt;group name="profile"&gt;
* &lt;needs&gt;globals&lt;/needs&gt;
* &lt;needs&gt;jquery&lt;/needs&gt;
* &lt;file type="script"&gt;js/profile.js&lt;file/&gt;
* &lt;/group&gt;
* &lt;/ui&gt;
* </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;
}
}
}