/*
* Copyright (C) 2009 eXo Platform SAS.
*
* This is free software; you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as
* published by the Free Software Foundation; either version 2.1 of
* the License, or (at your option) any later version.
*
* This software is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this software; if not, write to the Free
* Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
* 02110-1301 USA, or see the FSF site: http://www.fsf.org.
*/
package org.exoplatform.web.application.javascript;
import java.io.IOException;
import java.io.InputStream;
import java.net.URLConnection;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import javax.servlet.ServletContext;
import javax.xml.parsers.ParserConfigurationException;
import org.codehaus.plexus.components.io.fileselectors.FileInfo;
import org.codehaus.plexus.components.io.fileselectors.IncludeExcludeFileSelector;
import org.exoplatform.commons.utils.I18N;
import org.exoplatform.services.log.ExoLogger;
import org.exoplatform.services.log.Log;
import org.exoplatform.web.application.javascript.Javascript.Remote;
import org.gatein.common.xml.XMLTools;
import org.gatein.portal.controller.resource.ResourceId;
import org.gatein.portal.controller.resource.ResourceScope;
import org.gatein.portal.controller.resource.script.FetchMode;
import org.gatein.portal.controller.resource.script.Module.Local.Content;
import org.gatein.portal.controller.resource.script.StaticScriptResource;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
import com.google.javascript.rhino.TokenStream;
/**
* @author <a href="mailto:hoang281283@gmail.com">Minh Hoang TO</a>
* @version $Id$
*
*/
public class JavascriptConfigParser {
public static final String JAVA_SCRIPT_TAG = "javascript";
public static final String JAVA_SCRIPT_PARAM = "param";
public static final String JAVA_SCRIPT_MODULE = "js-module";
public static final String JAVA_SCRIPT_PATH = "js-path";
public static final String JAVA_SCRIPT_PRIORITY = "js-priority";
public static final String JAVA_SCRIPT_PORTAL_NAME = "portal-name";
public static final String LEGACY_JAVA_SCRIPT = "merged";
/** . */
public static final String SCRIPT_TAG = "script";
public static final String SCRIPTS_TAG = "scripts";
/** . */
public static final String PORTLET_TAG = "portlet";
/** . */
public static final String PORTAL_TAG = "portal";
/** . */
public static final String RESOURCE_TAG = "resource";
/** . */
public static final String SCOPE_TAG = "scope";
/** . */
public static final String MODULE_TAG = "module";
/** . */
public static final String PATH_TAG = "path";
/** . */
public static final String DEPENDS_TAG = "depends";
/** . */
public static final String URL_TAG = "url";
/** . */
public static final String AS_TAG = "as";
/** . */
public static final String ADAPTER_TAG = "adapter";
/** . */
public static final String INCLUDE_TAG = "include";
/** . */
public static final String GROUP_TAG = "load-group";
public static final String AMD_TAG = "amd";
public static final String NATIVE_AMD_TAG = "native-amd";
public static final String FILESET_TAG = "fileset";
public static final String DIRECTORY_TAG = "directory";
public static final String INCLUDES_TAG = "includes";
public static final String EXCLUDE_TAG = "exclude";
public static final String EXCLUDES_TAG = "excludes";
public static final String PATH_ENTRY_TAG = "path-entry";
public static final String PREFIX_TAG = "prefix";
public static final String TARGET_PATH_TAG = "target-path";
/** . */
private final ServletContext servletContext;
private final String contextPath;
private final Document document;
private static final Log log = ExoLogger.getExoLogger(JavascriptConfigParser.class);
private static final String[] PARSEABLE_SCRIPT_TAGS = new String[] {JAVA_SCRIPT_TAG, MODULE_TAG, SCRIPTS_TAG, PORTLET_TAG, PORTAL_TAG};
public JavascriptConfigParser(ServletContext servletContext, Document document) throws SAXException, IOException, ParserConfigurationException {
this.servletContext = servletContext;
this.contextPath = servletContext.getContextPath();
this.document = document;
}
/**
* Transforms the underlying {@link #document} into a new {@link ScriptResources} instance.
*
* @return a {@link ScriptResources}
*/
public ScriptResources parse() {
ScriptResources result = new ScriptResources(servletContext.getContextPath());
Element element = document.getDocumentElement();
for (String tagName : PARSEABLE_SCRIPT_TAGS) {
for (Element childElt : XMLTools.getChildren(element, tagName)) {
Collection<ScriptResourceDescriptor> descriptors = parseScripts(childElt);
if (descriptors != null) {
result.getScriptResourceDescriptors().addAll(descriptors);
}
}
}
parseAmd(element, result);
return result;
}
private Collection<ScriptResourceDescriptor> parseScripts(Element element) {
LinkedHashMap<ResourceId, ScriptResourceDescriptor> scripts = new LinkedHashMap<ResourceId, ScriptResourceDescriptor>();
if (JAVA_SCRIPT_TAG.equals(element.getTagName())) {
try {
NodeList nodes = element.getElementsByTagName(JAVA_SCRIPT_PARAM);
int length = nodes.getLength();
for (int i = 0; i < length; i++) {
Element param_ele = (Element) nodes.item(i);
String js_path = param_ele.getElementsByTagName(JAVA_SCRIPT_PATH).item(0).getFirstChild().getNodeValue();
//
log.warn(
"<javascript> tag define for javascript: {} has ben deprecated, please use <scripts> or <module> instead",
js_path);
//
int priority;
try {
priority = Integer.valueOf(
param_ele.getElementsByTagName(JAVA_SCRIPT_PRIORITY).item(0).getFirstChild().getNodeValue())
.intValue();
} catch (Exception e) {
priority = Integer.MAX_VALUE;
}
String portalName = null;
try {
portalName = param_ele.getElementsByTagName(JAVA_SCRIPT_PORTAL_NAME).item(0).getFirstChild()
.getNodeValue();
} catch (Exception e) {
// portal-name is null
}
Javascript js;
if (portalName == null) {
js = Javascript.create(new ResourceId(ResourceScope.SHARED, LEGACY_JAVA_SCRIPT), js_path, contextPath,
priority);
} else {
js = Javascript
.create(new ResourceId(ResourceScope.PORTAL, portalName), js_path, contextPath, priority);
}
//
ScriptResourceDescriptor desc = scripts.get(js.getResource());
if (desc == null) {
scripts.put(js.getResource(),
desc = new ScriptResourceDescriptor(js.getResource(), FetchMode.IMMEDIATE));
}
desc.modules.add(js);
}
} catch (Exception ex) {
log.error(ex.getMessage(), ex);
}
} else if (PORTAL_TAG.equals(element.getTagName()) || PORTLET_TAG.equals(element.getTagName())) {
String resourceName = XMLTools.asString(XMLTools.getUniqueChild(element, "name", true));
ResourceScope resourceScope;
if (PORTLET_TAG.equals(element.getTagName())) {
resourceName = contextPath.substring(1) + "/" + resourceName;
resourceScope = ResourceScope.PORTLET;
} else {
resourceScope = ResourceScope.PORTAL;
}
ResourceId id = new ResourceId(resourceScope, resourceName);
FetchMode fetchMode;
String group = null;
Element resourceElt = XMLTools.getUniqueChild(element, MODULE_TAG, false);
if (resourceElt != null) {
fetchMode = FetchMode.ON_LOAD;
if (XMLTools.getUniqueChild(resourceElt, URL_TAG, false) == null) {
group = parseGroup(resourceElt);
}
} else {
resourceElt = XMLTools.getUniqueChild(element, SCRIPTS_TAG, false);
fetchMode = FetchMode.IMMEDIATE;
}
if (resourceElt != null) {
ScriptResourceDescriptor desc = scripts.get(id);
if (desc == null) {
Element nativeAmdTag = XMLTools.getUniqueChild(element, NATIVE_AMD_TAG, false);
boolean isNativeAmd = nativeAmdTag != null && Boolean.parseBoolean(XMLTools.asString(nativeAmdTag, true).toLowerCase());
desc = new ScriptResourceDescriptor(id, fetchMode, parseOptString(element, AS_TAG), group, isNativeAmd);
} else {
desc.fetchMode = fetchMode;
}
parseDesc(resourceElt, desc);
scripts.put(id, desc);
}
} else if (MODULE_TAG.equals(element.getTagName()) || SCRIPTS_TAG.equals(element.getTagName())) {
String resourceName = XMLTools.asString(XMLTools.getUniqueChild(element, "name", true));
ResourceId id = new ResourceId(ResourceScope.SHARED, resourceName);
FetchMode fetchMode;
String group = null;
if (MODULE_TAG.equals(element.getTagName())) {
fetchMode = FetchMode.ON_LOAD;
if (XMLTools.getUniqueChild(element, URL_TAG, false) == null) {
group = parseGroup(element);
}
} else {
fetchMode = FetchMode.IMMEDIATE;
}
ScriptResourceDescriptor desc = scripts.get(id);
if (desc == null) {
Element nativeAmdTag = XMLTools.getUniqueChild(element, NATIVE_AMD_TAG, false);
boolean isNativeAmd = nativeAmdTag != null && Boolean.parseBoolean(XMLTools.asString(nativeAmdTag, true).toLowerCase());
desc = new ScriptResourceDescriptor(id, fetchMode, parseOptString(element, AS_TAG), group, isNativeAmd);
}
parseDesc(element, desc);
scripts.put(id, desc);
} else {
// ???
}
//
return scripts.values();
}
private void parseDesc(Element element, ScriptResourceDescriptor desc) {
Element urlElement = XMLTools.getUniqueChild(element, URL_TAG, false);
if (urlElement != null) {
String remoteURL = XMLTools.asString(urlElement);
desc.id.setFullId(false);
Remote script = new Javascript.Remote(desc.id, contextPath, remoteURL, 0);
desc.modules.add(script);
} else {
for (Element localeElt : XMLTools.getChildren(element, "supported-locale")) {
String localeValue = XMLTools.asString(localeElt);
Locale locale = I18N.parseTagIdentifier(localeValue);
desc.supportedLocales.add(locale);
}
for (Element scriptElt : XMLTools.getChildren(element, SCRIPT_TAG)) {
String resourceBundle = parseOptString(scriptElt, "resource-bundle");
List<Content> contents = new LinkedList<Content>();
Element adapter = XMLTools.getUniqueChild(scriptElt, ADAPTER_TAG, false);
String scriptPath = parseOptString(scriptElt, "path");
if (scriptPath != null) {
contents.add(new Content(scriptPath));
} else if (adapter != null) {
NodeList childs = adapter.getChildNodes();
for (int i = 0; i < childs.getLength(); i++) {
Node item = childs.item(i);
if (item instanceof Element) {
Element include = (Element) item;
if (INCLUDE_TAG.equals(include.getTagName())) {
contents.add(new Content(XMLTools.asString(include, true)));
}
} else if (item.getNodeType() == Node.TEXT_NODE) {
contents.add(new Content(item.getNodeValue().trim(), false));
}
}
}
Content[] tmp = contents.toArray(new Content[contents.size()]);
//
Javascript script = new Javascript.Local(desc.id, contextPath, tmp, resourceBundle, 0);
desc.modules.add(script);
}
}
for (Element moduleElt : XMLTools.getChildren(element, "depends")) {
Element dependencyElt = XMLTools.getUniqueChild(moduleElt, "module", false);
if (dependencyElt == null) {
dependencyElt = XMLTools.getUniqueChild(moduleElt, "scripts", false);
}
ResourceId resourceId = new ResourceId(ResourceScope.SHARED, XMLTools.asString(dependencyElt));
DependencyDescriptor dependency = new DependencyDescriptor(resourceId, parseOptString(moduleElt, AS_TAG),
parseOptString(moduleElt, RESOURCE_TAG));
desc.dependencies.add(dependency);
}
}
private String parseGroup(Element element) {
Element group = XMLTools.getUniqueChild(element, GROUP_TAG, false);
if (group != null) {
String grpName = XMLTools.asString(group, true);
if (grpName.isEmpty()) {
grpName = null;
}
return grpName;
} else {
return null;
}
}
private String parseOptString(Element element, String childTag) {
Element childElt = XMLTools.getUniqueChild(element, childTag, false);
return childElt == null ? null : XMLTools.asString(childElt, true);
}
/**
* Parses {@code <includes>} or {@code <excludes>}.
*
* @param filesetElement
* @param cludesTag {@code "includes"} or {@code "excludes"}
* @param cludeTag {@code "include"} or {@code "exclude"}
* @return
*/
private String[] parseCludes(Element filesetElement, String cludesTag, String cludeTag) {
Element cludesElement = XMLTools.getUniqueChild(filesetElement, cludesTag, false);
if (cludesElement != null) {
List<Element> cludeElements = XMLTools.getChildren(cludesElement, cludeTag);
List<String> result = new ArrayList<String>(cludeElements.size());
for (Element cludeElement : cludeElements) {
result.add(XMLTools.asString(cludeElement, true));
}
return result.toArray(new String[result.size()]);
} else {
return null;
}
}
private void parseAmd(Element documentElement, final ScriptResources result) {
Element amd = XMLTools.getUniqueChild(documentElement, AMD_TAG, false);
if (amd != null) {
Map<String, List<String>> paths = result.getPaths();
for (Element pathEntry : XMLTools.getChildren(amd, PATH_ENTRY_TAG)) {
/* path-entry can look like this:
* <path-entry>
* <prefix>/dojo</prefix>
* <target-path>http://my-cdn.com/dojo/1.9.2</target-path>
* <target-path>http://other-cdn.com/dojo/1.9.2</target-path>
* <target-path>/local/path/dojo/1.9.2</target-path>
* </path-entry>
*
* esp. note that multiple target-paths are possible
* the second target-path and all following target-paths are interpreted as fallback
* urls by require.js
*/
Element prefixElement = XMLTools.getUniqueChild(pathEntry, PREFIX_TAG, true);
String prefix = XMLTools.asString(prefixElement, true);
List<Element> targetPathElements = XMLTools.getChildren(pathEntry, TARGET_PATH_TAG);
List<String> targetPaths = new ArrayList<String>(targetPathElements.size());
for (Element targetPathElement : targetPathElements) {
String targetPath = XMLTools.asString(targetPathElement, true);
targetPaths.add(targetPath);
}
paths.put(prefix, targetPaths);
}
for (Element fileset : XMLTools.getChildren(amd, FILESET_TAG)) {
Element dirElement = XMLTools.getUniqueChild(fileset, DIRECTORY_TAG, true);
String dir = XMLTools.asString(dirElement, true);
if (dir.charAt(0) != AmdResourceScanner.FILE_SEPARATOR) {
dir = new StringBuilder(dir.length() +1).append(AmdResourceScanner.FILE_SEPARATOR).append(dir).toString();
}
final String directory;
final String directorySlash;
if (dir.charAt(dir.length() - 1) == AmdResourceScanner.FILE_SEPARATOR) {
directory = dir.substring(0, dir.length() -1);
directorySlash = dir;
} else {
directory = dir;
directorySlash = dir + AmdResourceScanner.FILE_SEPARATOR;
}
String[] includes = parseCludes(fileset, INCLUDES_TAG, INCLUDE_TAG);
String[] excludes = parseCludes(fileset, EXCLUDES_TAG, EXCLUDE_TAG);
final AmdResourceScanner.AmdResourceVisitor visitor = new AmdResourceScanner.AmdResourceVisitor() {
@Override
public void visit(String amdFile, long lastModified) {
int amdFileLength = amdFile.length();
if (amdFileLength >= 3) {
/* case-insensitive matching. For performance reasons, we use String.charAt(char)
* rather some possibly more concise alternative with substring().equalsIgnoreCase(),
* toLowerCase().endsWith() or even a regular expression. */
char lastChar = amdFile.charAt(amdFileLength - 1);
char lastButOneChar = amdFile.charAt(amdFileLength - 2);
char lastButTwoChar = amdFile.charAt(amdFileLength - 3);
if (lastButTwoChar == '.'
&& (lastButOneChar == 'j' || lastButOneChar == 'J')
&& (lastChar == 's' || lastChar == 'S')) {
/* ends with .js */
/* if dir is somethig like /js/amd and amdFile is something like /js/amd/package/mymodule.js
* then fqModuleName will be package/mymodule */
String fqModuleName = amdFile.substring(directorySlash.length(), amdFileLength - 3);
String alias = toModuleAlias(fqModuleName);
ScriptResourceDescriptor d = new ScriptResourceDescriptor(
new ResourceId(ResourceScope.SHARED, fqModuleName),
FetchMode.ON_LOAD, alias, null, true);
Javascript js = Javascript.create(
new ResourceId(ResourceScope.SHARED, LEGACY_JAVA_SCRIPT),
amdFile, contextPath, Integer.MAX_VALUE);
d.modules.add(js);
result.getScriptResourceDescriptors().add(d);
return;
}
}
/* amdFile is not ending with *.js */
/* directory.length() - 1 because we want the resourceURI to start with '/' */
String resourceURI = amdFile.substring(directorySlash.length() - 1, amdFileLength);
StaticScriptResource r = new StaticScriptResource(contextPath, directory, resourceURI, lastModified);
result.getStaticScriptResources().add(r);
}
};
new AmdResourceScanner(directorySlash, includes, excludes, servletContext).scan(visitor);
}
}
}
/**
* Converts {@code "path/to/my/module.js"} into {@code "pathToMyModule"}.
* @param fqModulePath
* @return
*/
private static String toModuleAlias(String fqModulePath) {
StringBuilder result = new StringBuilder(fqModulePath.length());
boolean nextUpper = false;
int len = fqModulePath.length();
for (int i = 0; i < len; i++) {
char ch = fqModulePath.charAt(i);
while (result.length() == 0 ? !Character.isJavaIdentifierStart(ch) : !Character.isJavaIdentifierPart(ch)) {
i++;
if (i >= len) {
return result.toString();
}
ch = fqModulePath.charAt(i);
nextUpper = true;
}
result.append(nextUpper ? Character.toUpperCase(ch) : ch);
nextUpper = false;
}
String strResult = result.toString();
if (TokenStream.isKeyword(strResult)) {
return result.append('_').toString();
} else {
return strResult;
}
}
/**
* A facade for a {@link IncludeExcludeFileSelector} using a {@link ServletContext} for listing
* resources.
*
* @see AmdResourceScanner.AmdResourceVisitor
*
* @author <a href="mailto:ppalaga@redhat.com">Peter Palaga</a>
*
*/
private static class AmdResourceScanner {
/**
* A visitor for handling paths selected by {@link AmdResourceScanner}.
*
* @author <a href="mailto:ppalaga@redhat.com">Peter Palaga</a>
*
*/
public interface AmdResourceVisitor {
/**
* Handles a resource path as returned by {@link ServletContext#getResourcePaths(String)}.
*
* @param path resource path as returned by {@link ServletContext#getResourcePaths(String)}
* @param lastModified UNIX timestamp in milliseconds.
*/
void visit(String path, long lastModified);
}
private static final char FILE_SEPARATOR = '/';
private final String directory;
private final ServletContext servletContext;
private final IncludeExcludeFileSelector selector;
/**
* @param directory
* @param includes
* @param excludes
* @param servletContext
*/
public AmdResourceScanner(String directory, String[] includes, String[] excludes, ServletContext servletContext) {
super();
IncludeExcludeFileSelector sel = new IncludeExcludeFileSelector();
sel.setIncludes(includes);
sel.setExcludes(excludes);
this.selector = sel;
this.directory = directory;
this.servletContext = servletContext;
}
/**
* Scans {@link #directory} applying {@code includes} and {@code excludes} as supplied to the constructor.
*
* @param visitor
*/
public void scan(AmdResourceVisitor visitor) {
scanDirectory(directory, visitor);
}
/**
* @param directory
* @return
*/
private void scanDirectory(String directory, AmdResourceVisitor visitor) {
@SuppressWarnings("unchecked")
Set<String> paths = servletContext.getResourcePaths(directory);
if (paths != null && paths.size() > 0) {
for (String path : paths) {
String relName = path.substring(this.directory.length());
if (isDirectory(path)) {
scanDirectory(path, visitor);
} else {
FileInfo fileInfo = new SimpleFileInfo(relName);
try {
if (selector.isSelected(fileInfo)) {
URLConnection cn = servletContext.getResource(path).openConnection();
long lastModified = cn.getLastModified();
visitor.visit(path, lastModified);
}
} catch (IOException e) {
throw new RuntimeException("Could not filter path '"+ path +"'", e);
}
}
}
}
}
/**
* @param path
* @return
*/
private boolean isDirectory(String path) {
return path.charAt(path.length() - 1) == FILE_SEPARATOR;
}
}
/**
* A basic implementation of {@link FileInfo}. Note that {@link FileInfo#getContents()}
* is unsupported in this implementation.
*
* @author <a href="mailto:ppalaga@redhat.com">Peter Palaga</a>
*
*/
private static class SimpleFileInfo implements FileInfo {
/**
* @param name
* @param isFile
*/
public SimpleFileInfo(String name) {
super();
this.name = name;
}
private final String name;
/**
* @see org.codehaus.plexus.components.io.fileselectors.FileInfo#getName()
*/
@Override
public String getName() {
return name;
}
/**
* Unsupported in this implementation. Always throws a {@link UnsupportedOperationException}.
*
* @see org.codehaus.plexus.components.io.fileselectors.FileInfo#getContents()
*/
@Override
public InputStream getContents() throws IOException {
throw new UnsupportedOperationException("FileInfo.getContents() unsupported in "+ this.getClass().getName());
}
/**
* Returns always {@code true} in this implementation.
* @see org.codehaus.plexus.components.io.fileselectors.FileInfo#isFile()
*/
@Override
public boolean isFile() {
return true;
}
/**
* Returns always {@code false} in this implementation.
* @see org.codehaus.plexus.components.io.fileselectors.FileInfo#isDirectory()
*/
@Override
public boolean isDirectory() {
return false;
}
}
}