Commits

Stephen McKamey committed 6b755ac

adding ability to generate cache manifest for site pages

Comments (0)

Files changed (14)

duel-staticapps-maven-plugin/pom.xml

 
 	<groupId>org.duelengine</groupId>
 	<artifactId>duel-staticapps-maven-plugin</artifactId>
-	<version>0.9.5</version>
+	<version>0.9.6</version>
 	<packaging>maven-plugin</packaging>
 
 	<name>DUEL Static Apps Maven Plugin</name>

duel-staticapps-maven-plugin/src/main/java/org/duelengine/duel/staticapps/maven/CacheManifestGeneratorMojo.java

+package org.duelengine.duel.staticapps.maven;
+
+import org.duelengine.duel.staticapps.SiteBuilder;
+import org.duelengine.duel.staticapps.SiteConfig;
+
+/**
+ * Generates cache manifests from DUEL-based WAR
+ *
+ * @goal manifests
+ * @phase prepare-package
+ */
+public class CacheManifestGeneratorMojo extends SiteMojo {
+
+	@Override
+	protected void execute(SiteConfig config, ClassLoader classLoader)
+			throws Exception {
+
+		// generate any manifests defined by the config
+		new SiteBuilder(classLoader).generateManifests(config);
+	}
+}

duel-staticapps-maven-plugin/src/main/java/org/duelengine/duel/staticapps/maven/MavenLoggerAdapter.java

 	}
 
 	@Override
-	public void debug(String format, Object[] arg1) {
+	public void debug(String format, Object... arg1) {
 		debug(String.format(format, arg1));
 	}
 
 	}
 
 	@Override
-	public void error(String format, Object[] arg1) {
+	public void error(String format, Object... arg1) {
 		error(String.format(format, arg1));
 	}
 
 	}
 
 	@Override
-	public void info(String format, Object[] arg1) {
+	public void info(String format, Object... arg1) {
 		info(String.format(format, arg1));
 	}
 
 	}
 
 	@Override
-	public void trace(String msg, Object[] arg1) {
+	public void trace(String msg, Object... arg1) {
 		// NOOP
 	}
 
 	}
 
 	@Override
-	public void warn(String format, Object[] arg1) {
+	public void warn(String format, Object... arg1) {
 		warn(String.format(format, arg1));
 	}
 

duel-staticapps-maven-plugin/src/main/java/org/duelengine/duel/staticapps/maven/SiteGeneratorMojo.java

 package org.duelengine.duel.staticapps.maven;
 
-import java.io.File;
-import java.io.FileNotFoundException;
-import java.io.IOException;
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.net.URLClassLoader;
-import java.util.ArrayList;
-import java.util.List;
-
-import org.apache.maven.artifact.DependencyResolutionRequiredException;
-import org.apache.maven.plugin.AbstractMojo;
-import org.apache.maven.plugin.MojoExecutionException;
-import org.apache.maven.plugin.descriptor.PluginDescriptor;
-import org.apache.maven.plugin.logging.Log;
-import org.apache.maven.project.MavenProject;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import org.codehaus.plexus.classworlds.realm.ClassRealm;
 import org.duelengine.duel.staticapps.SiteBuilder;
 import org.duelengine.duel.staticapps.SiteConfig;
-import org.duelengine.duel.utils.FileUtil;
 
 /**
  * Generates static app from DUEL-based WAR
  *
  * @goal generate
- * @phase package
+ * @phase prepare-package
  */
-public class SiteGeneratorMojo extends AbstractMojo {
-
-	// http://maven.apache.org/ref/3.0.4/maven-model/maven.html#class_build
-
-	/**
-	 * The project currently being built.
-	 * 
-	 * @parameter default-value="${project}"
-	 * @required
-	 * @readonly
-	 */
-	private MavenProject project;
-
-	/**
-	 * The plugin descriptor
-	 * 
-	 * @parameter default-value="${descriptor}"
-	 */
-	private PluginDescriptor descriptor;
-
-	/**
-	 * Location of the configuration settings
-	 * 
-	 * @parameter default-value="${project.basedir}/staticapp.json"
-	 */
-	private String configPath;
+public class SiteGeneratorMojo extends SiteMojo {
 
 	@Override
-	public void setLog(Log log) {
-		super.setLog(log);
+	protected void execute(SiteConfig config, ClassLoader classLoader)
+			throws Exception {
 
-		MavenLoggerAdapterFactory.setMavenLogger(log);
-	};
-
-	public void execute()
-		throws MojoExecutionException {
-
-		Log log = this.getLog();
-
-		ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
-		try {
-			// http://stackoverflow.com/q/871708/43217
-			log.info("adding build dependencies and target to classPath");
-			ClassRealm realm = descriptor.getClassRealm();
-			List<String> runtimeClasspathElements = project.getRuntimeClasspathElements();
-			List<URL> runtimeUrls = new ArrayList<URL>(runtimeClasspathElements.size());
-			for (String element : runtimeClasspathElements) {
-				try {
-					URL elementURL = FileUtil.getCanonicalFile(element).toURI().toURL();
-					runtimeUrls.add(elementURL);
-					if (realm != null) {
-						realm.addURL(elementURL);
-					}
-
-				} catch (MalformedURLException ex) {
-					log.error(ex);
-				}
-			}
-			classLoader = new URLClassLoader(runtimeUrls.toArray(new URL[runtimeUrls.size()]), classLoader);
-			Thread.currentThread().setContextClassLoader(classLoader);
-
-		} catch (DependencyResolutionRequiredException ex) {
-			log.error(ex);
-		}
-
-		try {
-			File configFile = new File(configPath);
-
-			if (!configFile.isFile()) {
-				throw new FileNotFoundException(configFile.getPath());
-			}
-
-			// read config
-			SiteConfig config = new ObjectMapper().reader(SiteConfig.class).readValue(configFile);
-
-			// ensure paths are relative from config
-			config
-				.sourceDir(new File(configFile.getParentFile(), config.sourceDir()).getPath())
-				.targetDir(new File(configFile.getParentFile(), config.targetDir()).getPath());
-
-			// build site defined by config
-			new SiteBuilder(classLoader).build(config);
-
-		} catch (IOException e) {
-			log.error(e);
-		}
+		// generate a static site defined by the config
+		new SiteBuilder(classLoader).build(config);
 	}
 }

duel-staticapps-maven-plugin/src/main/java/org/duelengine/duel/staticapps/maven/SiteMojo.java

+package org.duelengine.duel.staticapps.maven;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.maven.artifact.DependencyResolutionRequiredException;
+import org.apache.maven.plugin.AbstractMojo;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugin.descriptor.PluginDescriptor;
+import org.apache.maven.plugin.logging.Log;
+import org.apache.maven.project.MavenProject;
+import org.codehaus.plexus.classworlds.realm.ClassRealm;
+import org.duelengine.duel.staticapps.SiteConfig;
+import org.duelengine.duel.utils.FileUtil;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+/**
+ * Base implementation of Mojo which loads a staticapps config file.
+ */
+public abstract class SiteMojo extends AbstractMojo {
+
+	// http://maven.apache.org/ref/3.0.4/maven-model/maven.html#class_build
+
+	/**
+	 * The project currently being built.
+	 * 
+	 * @parameter default-value="${project}"
+	 * @required
+	 * @readonly
+	 */
+	private MavenProject project;
+
+	/**
+	 * The plugin descriptor
+	 * 
+	 * @parameter default-value="${descriptor}"
+	 */
+	private PluginDescriptor descriptor;
+
+	/**
+	 * Location of the configuration settings
+	 * 
+	 * @parameter default-value="${project.basedir}/staticapp.json"
+	 */
+	private String configPath;
+
+	@Override
+	public void setLog(Log log) {
+		super.setLog(log);
+
+		MavenLoggerAdapterFactory.setMavenLogger(log);
+	};
+
+	public void execute()
+			throws MojoExecutionException {
+
+		try {
+			SiteConfig config = loadConfig();
+
+			ClassLoader classLoader = getClassLoader();
+
+			execute(config, classLoader);
+
+		} catch (Exception ex) {
+			this.getLog().error(ex);
+			throw new MojoExecutionException(ex.getMessage(), ex);
+		}
+	}
+
+	protected abstract void execute(SiteConfig config, ClassLoader classLoader)
+			throws Exception;
+
+	protected SiteConfig loadConfig()
+			throws IOException {
+
+		File configFile = new File(configPath);
+		if (!configFile.isFile()) {
+			throw new FileNotFoundException(configFile.getPath());
+		}
+
+		// deserialize config
+		SiteConfig config = new ObjectMapper().reader(SiteConfig.class).readValue(configFile);
+
+		// ensure paths are relative from config
+		if (config.sourceDir() != null) {
+			config.sourceDir(new File(configFile.getParentFile(), config.sourceDir()).getPath());
+		}
+		if (config.targetDir() != null) {
+			config.targetDir(new File(configFile.getParentFile(), config.targetDir()).getPath());
+		}
+
+		return config;
+	}
+
+	protected ClassLoader getClassLoader() {
+		Log log = this.getLog();
+
+		ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
+		try {
+			// http://stackoverflow.com/q/871708/43217
+			log.info("adding build dependencies and target to classPath");
+			ClassRealm realm = descriptor.getClassRealm();
+			List<String> runtimeClasspathElements = project.getRuntimeClasspathElements();
+			List<URL> runtimeUrls = new ArrayList<URL>(runtimeClasspathElements.size());
+			for (String element : runtimeClasspathElements) {
+				try {
+					URL elementURL = FileUtil.getCanonicalFile(element).toURI().toURL();
+					runtimeUrls.add(elementURL);
+					if (realm != null) {
+						realm.addURL(elementURL);
+					}
+
+				} catch (MalformedURLException ex) {
+					log.error(ex);
+				}
+			}
+			classLoader = new URLClassLoader(runtimeUrls.toArray(new URL[runtimeUrls.size()]), classLoader);
+			Thread.currentThread().setContextClassLoader(classLoader);
+
+		} catch (DependencyResolutionRequiredException ex) {
+			log.error(ex);
+		}
+		return classLoader;
+	}
+}

duel-staticapps/example-config.json

 					"foo": "bar",
 					"blah": "yada"
 				},
-				"extras": null
+				"extras": null,
+				"appCache": {
+					"manifest": "home.appcache",
+					"version": "v2",
+					"cache": [
+						"/favicon.ico"
+					],
+					"fallback": {
+						"/": "/index.html"
+					},
+					"network": [
+						"*"
+					]
+				}
 			},
 		"error.html":
 			{

duel-staticapps/pom.xml

 
 	<groupId>org.duelengine</groupId>
 	<artifactId>duel-staticapps</artifactId>
-	<version>0.9.5</version>
+	<version>0.9.6</version>
 	<packaging>jar</packaging>
 
 	<name>DUEL Static Apps</name>
 		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
 
 		<duel.version>0.8.7</duel.version>
-		<jackson.version>2.2.2</jackson.version>
+		<jackson.version>2.3.0</jackson.version>
 		<codec.version>1.8</codec.version>
 		<slf4j.version>1.7.5</slf4j.version>
 		<servlet.version>3.0.1</servlet.version>

duel-staticapps/src/main/java/org/duelengine/duel/staticapps/CacheManifest.java

+package org.duelengine.duel.staticapps;
+
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public class CacheManifest {
+
+	private String manifest;
+	private String version;
+	private Set<String> cache;
+	private Map<String, String> fallback;
+	private Set<String> network;
+
+	@JsonProperty
+	public String manifest() {
+		return manifest;
+	}
+
+	@JsonProperty
+	public CacheManifest manifest(String value) {
+		manifest = (value != null) ? value.trim() : null;
+		return this;
+	}
+
+	@JsonProperty
+	public String version() {
+		return version;
+	}
+
+	@JsonProperty
+	public CacheManifest version(String value) {
+		version = (value != null) ? value.trim() : null;
+		return this;
+	}
+
+	@JsonProperty
+	public Set<String> cache() {
+		return cache;
+	}
+
+	@JsonProperty
+	public CacheManifest cache(Set<String> value) {
+		cache = value;
+		return this;
+	}
+
+	@JsonProperty
+	public Map<String, String> fallback() {
+		return fallback;
+	}
+
+	@JsonProperty
+	public CacheManifest fallbacks(Map<String, String> value) {
+		fallback = value;
+		return this;
+	}
+
+	@JsonProperty
+	public Set<String> network() {
+		return network;
+	}
+
+	@JsonProperty
+	public CacheManifest network(Set<String> value) {
+		network = value;
+		return this;
+	}
+
+	@JsonIgnore
+	public CacheManifest addCachePaths(Collection<String> value) {
+		if (cache == null) {
+			cache = new HashSet<String>();
+		}
+		cache.addAll(value);
+		return this;
+	}
+}

duel-staticapps/src/main/java/org/duelengine/duel/staticapps/CacheManifestWriter.java

+package org.duelengine.duel.staticapps;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import org.duelengine.duel.utils.FileUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class CacheManifestWriter {
+	private static final Logger log = LoggerFactory.getLogger(CacheManifestWriter.class);
+	private static final char NEWLINE = '\n';
+	private static final String HEADER = "CACHE MANIFEST";
+	private static final String COMMENT = "# ";
+	private static final String CACHE = "CACHE:";
+	private static final String FALLBACK = "FALLBACK:";
+	private static final String NETWORK = "NETWORK:";
+
+	public void write(File outputDir, CacheManifest cacheManifest) {
+		if (outputDir == null) {
+			throw new NullPointerException("outputDir");
+		}
+
+		File manifestFile = new File(outputDir, cacheManifest.manifest());
+		FileUtil.prepSavePath(manifestFile);
+
+		log.info("Generating cache manifest: "+manifestFile);
+
+		FileWriter writer = null;
+		try {
+			writer = new FileWriter(manifestFile);
+
+			write(writer, cacheManifest);
+
+		} catch (Exception ex) {
+			log.error(ex.getMessage(), ex);
+
+		} finally {
+			if (writer != null) {
+				try {
+					writer.flush();
+					writer.close();
+				} catch (IOException ex) {}
+			}
+		}
+	}
+
+	private void write(Appendable output, CacheManifest cacheManifest)
+			throws IOException {
+		output.append(HEADER).append(NEWLINE);
+		if (cacheManifest.version() != null && !cacheManifest.version().isEmpty()) {
+			output.append(COMMENT).append(cacheManifest.version()).append(NEWLINE);
+		}
+		output.append(NEWLINE);
+
+		if (cacheManifest.cache() != null && !cacheManifest.cache().isEmpty()) {
+			// sort to ensure the resources don't shift positions
+			List<String> paths = new ArrayList<String>(cacheManifest.cache());
+			Collections.sort(paths);
+
+			output.append(CACHE).append(NEWLINE);
+			for (String path : paths) {
+				output.append(path).append(NEWLINE);
+			}
+			output.append(NEWLINE);
+		}
+
+		if (cacheManifest.fallback() != null && !cacheManifest.fallback().isEmpty()) {
+			// sort to ensure the resources don't shift positions
+			List<String> paths = new ArrayList<String>(cacheManifest.fallback().keySet());
+			Collections.sort(paths);
+
+			output.append(FALLBACK).append(NEWLINE);
+			for (String path : paths) {
+				output.append(path).append(' ').append(cacheManifest.fallback().get(path)).append(NEWLINE);
+			}
+			output.append(NEWLINE);
+		}
+
+		if (cacheManifest.network() != null && !cacheManifest.network().isEmpty()) {
+			// sort to ensure the resources don't shift positions
+			List<String> paths = new ArrayList<String>(cacheManifest.network());
+			Collections.sort(paths);
+
+			output.append(NETWORK).append(NEWLINE);
+			for (String path : paths) {
+				output.append(path).append(NEWLINE);
+			}
+		}
+	}
+}

duel-staticapps/src/main/java/org/duelengine/duel/staticapps/NeverCacheFilter.java

+package org.duelengine.duel.staticapps;
+
+import java.io.IOException;
+
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * Sets cache control to "never" cache & enables cross-origin access.
+ */
+public class NeverCacheFilter implements Filter {
+
+	public void init(FilterConfig config) {}
+
+	public void destroy() {}
+
+	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+			throws IOException, ServletException {
+
+		if (response instanceof HttpServletResponse) {
+			HttpServletResponse httpResponse = (HttpServletResponse)response;
+
+			httpResponse.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); // HTTP 1.1
+			httpResponse.setHeader("Pragma", "no-cache"); // HTTP 1.0
+			httpResponse.setDateHeader("Expires", 0L); // HTTP 1.1 clients & proxies
+
+			// add header to enable cross-origin access
+			httpResponse.setHeader("Access-Control-Allow-Origin", "*");
+		}
+
+		chain.doFilter(request, response);
+	}
+}

duel-staticapps/src/main/java/org/duelengine/duel/staticapps/NeverExpireFilter.java

 
 	public void destroy() {}
 
-	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
+	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+			throws IOException, ServletException {
 
 		if (response instanceof HttpServletResponse) {
 			HttpServletResponse httpResponse = (HttpServletResponse)response;

duel-staticapps/src/main/java/org/duelengine/duel/staticapps/RoutingServlet.java

 		}
 
 		// TODO: expand routing capabilities beyond exact match, default-doc and catch-all
-		
+
 		SiteViewPage page = config.views().get(servletPath.substring(1));
 		if (page != null) {
 			log.info("routing: "+servletPath);

duel-staticapps/src/main/java/org/duelengine/duel/staticapps/SiteBuilder.java

 import java.io.FileWriter;
 import java.io.IOException;
 import java.net.URISyntaxException;
+import java.util.HashMap;
 import java.util.Locale;
 import java.util.Map;
 import java.util.ResourceBundle;
 public class SiteBuilder {
 
 	private static final Logger log = LoggerFactory.getLogger(SiteBuilder.class);
-	private static final int BUFFER_SIZE = 64*1024;//64K
+	private static final Appendable NOOP_OUTPUT = new Appendable() {
+		@Override
+		public Appendable append(CharSequence value, int start, int end) throws IOException { return this; }
+
+		@Override
+		public Appendable append(char value) throws IOException { return this; }
+
+		@Override
+		public Appendable append(CharSequence value) throws IOException { return this; }
+	};
+
+	private static final int BUFFER_SIZE = 1024*1024;//1MB
+	private final byte[] buffer = new byte[BUFFER_SIZE];
 	private final ClassLoader classLoader;
-	private final byte[] buffer = new byte[BUFFER_SIZE];
 
 	public SiteBuilder() {
 		this(Thread.currentThread().getContextClassLoader());
 			}
 		}
 
-		// link transformer which caches list of URLs
-		StaticLinkInterceptor linkInterceptor = null;
-		try {
-			String bundleName = config.cdnMap();
-			ResourceBundle cdnBundle =
-				(bundleName == null) || bundleName.isEmpty() ? null :
-				ResourceBundle.getBundle(bundleName, Locale.ROOT, classLoader);
-
-			bundleName = config.cdnLinksMap();
-			ResourceBundle cdnLinkBundle =
-				(bundleName == null) || bundleName.isEmpty() ? null :
-				ResourceBundle.getBundle(bundleName, Locale.ROOT, classLoader);
-
-			linkInterceptor = new StaticLinkInterceptor(config.cdnHost(), cdnBundle, cdnLinkBundle, config.isDevMode());
-
-		} catch (URISyntaxException ex) {
-			log.error(ex.getMessage(), ex);
-		}
+		// link transformer which also caches list of URLs
+		StaticLinkInterceptor linkInterceptor = createInterceptor(config);
 
 		FormatPrefs formatPrefs = new FormatPrefs()
 			.setEncoding(config.encoding())
 			.setIndent(config.isDevMode() ? "\t" : "")
 			.setNewline(config.isDevMode() ? "\n" : "");
 
+		Map<String, String> linkCache = null;
+
 		Map<String, SiteViewPage> views = config.views();
 		if (views != null) {
 			for (String targetPage : views.keySet()) {
 				SiteViewPage sitePage = views.get(targetPage);
 				log.info("Generating: "+sitePage.view()+" => "+targetPage);
 
+				if (sitePage.appCache() != null) {
+					if (linkCache == null) {
+						linkCache = new HashMap<String, String>();
+					}
+
+					// aggregate and reset so manifest only contains this page's resources
+					linkCache.putAll(linkInterceptor.getLinkCache());
+					linkInterceptor.getLinkCache().clear();
+				}
+
 				FileWriter writer = null;
 				try {
 					File targetFile = new File(targetDir, targetPage);
 						view.render(context);
 					}
 
+					CacheManifest cacheManifest = sitePage.appCache();
+					if (cacheManifest != null) {
+						cacheManifest.addCachePaths(linkInterceptor.getLinkCache().values());
+						new CacheManifestWriter().write(targetDir, cacheManifest);
+					}
+
 				} catch (Exception ex) {
 					log.error(ex.getMessage(), ex);
 
 			}
 		}
 
+		if ((linkCache != null) && !linkCache.isEmpty()) {
+			// restore any previously stored values
+			linkInterceptor.getLinkCache().putAll(linkCache);
+		}
+		linkCache = linkInterceptor.getLinkCache();
+
 		// copy static resources which are blindly requested by userAgents (e.g., "robots.txt", "favicon.ico")
 		String[] staticFiles = config.files();
 		if (staticFiles != null) {
 			}
 		}
 
-		Map<String, String> linkCache = linkInterceptor.getLinkCache();
+		// ensure that all referenced files are copied
 		for (String key : linkCache.keySet()) {
 			try {
 				copyResource(sourceDir, targetDir, key, linkCache.get(key));
 		}
 	}
 
+	public void generateManifests(SiteConfig config)
+			throws FileNotFoundException  {
+
+		if (config == null) {
+			throw new NullPointerException("config");
+		}
+
+		StaticLinkInterceptor linkInterceptor = null;
+
+		for (SiteViewPage sitePage : config.views().values()) {
+			if (sitePage.appCache() == null) {
+				// only find dependencies if manifest needs generating
+				continue;
+			}
+
+			if (linkInterceptor == null) {
+				linkInterceptor = createInterceptor(config);
+
+			} else if (!linkInterceptor.getLinkCache().isEmpty()) {
+				linkInterceptor.getLinkCache().clear();
+			}
+
+			try {
+				DuelContext context = new DuelContext()
+					.setLinkInterceptor(linkInterceptor)
+					.setData(sitePage.data())
+					.setOutput(NOOP_OUTPUT);
+
+				Map<String, Object> extras = sitePage.extras();
+				if (extras != null) {
+					// ambient client-side data
+					context.putExtras(extras);
+				}
+
+				DuelView view = sitePage.viewInstance(config.serverPrefix(), classLoader);
+				if (view != null) {
+					view.render(context);
+				}
+
+				sitePage.appCache().addCachePaths(linkInterceptor.getLinkCache().values());
+				new CacheManifestWriter().write(config.targetDirFile(), sitePage.appCache());
+			} catch (Exception ex) {
+				log.error(ex.getMessage(), ex);
+			}
+		}
+	}
+
+	private StaticLinkInterceptor createInterceptor(SiteConfig config) {
+		StaticLinkInterceptor linkInterceptor = null;
+		try {
+			String bundleName = config.cdnMap();
+			ResourceBundle cdnBundle =
+				(bundleName == null) || bundleName.isEmpty() ? null :
+				ResourceBundle.getBundle(bundleName, Locale.ROOT, classLoader);
+
+			bundleName = config.cdnLinksMap();
+			ResourceBundle cdnLinkBundle =
+				(bundleName == null) || bundleName.isEmpty() ? null :
+				ResourceBundle.getBundle(bundleName, Locale.ROOT, classLoader);
+
+			linkInterceptor = new StaticLinkInterceptor(config.cdnHost(), cdnBundle, cdnLinkBundle, config.isDevMode());
+
+		} catch (URISyntaxException ex) {
+			log.error(ex.getMessage(), ex);
+		}
+		return linkInterceptor;
+	}
+
 	private void copyResource(File sourceDir, File targetDir, String path, String cdnPath)
 			throws IOException {
 

duel-staticapps/src/main/java/org/duelengine/duel/staticapps/SiteViewPage.java

 
 import java.util.Map;
 
+import org.duelengine.duel.DuelView;
+
 import com.fasterxml.jackson.annotation.JsonProperty;
-import org.duelengine.duel.DuelView;
 
 public class SiteViewPage {
 
 	private String view;
 	private Object data;
 	private Map<String, Object> extras;
+	private CacheManifest appCache;
 
 	/**
 	 * @return the view name
 		return this;
 	}
 
+	@JsonProperty
+	public CacheManifest appCache() {
+		return appCache;
+	}
+
+	@JsonProperty
+	public SiteViewPage appCache(CacheManifest value) {
+		appCache = value;
+		return this;
+	}
+
 	/**
 	 * @return the view class
 	 * @throws ClassNotFoundException