/// <summary> /// Takes the url of a combined file, and returns its content, /// ready to be sent to the browser. /// The url does not relate to an actual file. The combined content /// only lives in cache. If it is not in cache, this method /// finds out which files are associated with the fileUrl, /// reads them, compresses the content and stores that in cache /// (as well as returning it). /// </summary> /// <param name="context"></param> /// <param name="combinedFileUrl"></param> /// <returns></returns> public static string Content( HttpContext context, string combinedFileUrl, bool minifyCSS, bool minifyJavaScript, UrlProcessor urlProcessor, out FileTypeUtilities.FileType fileType) { // Get the urlsHash from the combined file url. string urlsHash = UrlVersioner.UnversionedFilename(combinedFileUrl); // Based on that hash, get the compressed content of the combined file. string combinedFileContent = null; string newVersionId = null; GetContentVersion( context, urlsHash, urlProcessor, null, minifyCSS, minifyJavaScript, out combinedFileContent, out newVersionId, out fileType); if (combinedFileContent == null) { // combinedFileUrl matches an actual file on the server. Load that file // and return its content to the browser. Because this situation normally // only happens when a (already minified) library file could not be loaded // from a CDN (a rare event), or if we are in debug mode, there is no need // to minify the file. combinedFileContent = ""; string filePath = MapPath(combinedFileUrl, urlProcessor.ThrowExceptionOnMissingFile); if (filePath != null) { combinedFileContent = File.ReadAllText(filePath); } fileType = FileTypeUtilities.FileTypeOfUrl(combinedFileUrl); } return combinedFileContent; }
protected override void Render (HtmlTextWriter writer) { ConfigSection cs = ConfigSection.CurrentConfigSection (); // If we are not active, render the head section to the writer as is. if (!ConfigSection.OptionIsActive (cs.Active)) { base.Render (writer); return; } // -------------- UrlProcessor urlProcessor = new UrlProcessor ( cs.CookielessDomains, cs.MakeImageUrlsLowercase, cs.InsertVersionIdInImageUrls, ConfigSection.OptionIsActive (cs.EnableCookielessDomains), cs.PreloadAllImages, ConfigSection.OptionIsActive (cs.ExceptionOnMissingFile)); // -------------- // headHtml holds the html on the page making up the // head element, including the <head> tag itself. StringBuilder headHtmlSb = new StringBuilder (); base.Render (new HtmlTextWriter (new StringWriter (headHtmlSb))); // ------------ HeadAnalysis headAnalysis = null; if (cs.HeadCaching == ConfigSection.HeadCachingOption.None) { headAnalysis = new HeadAnalysis ( headHtmlSb.ToString (), null, cs.CombineCSSFiles, cs.CombineJavaScriptFiles, cs.MinifyCSS, cs.MinifyJavaScript, urlProcessor); } else { string headCacheKey = HeadCacheKey (HttpContext.Current.Request.Url, cs.HeadCaching); headAnalysis = (HeadAnalysis)HttpContext.Current.Cache [headCacheKey]; if (headAnalysis == null) { // The urls of the combined CSS and JavaScript files in the new head // are dependent on the versions of the actual files (because they contain the version // ids). // totalFileNames will be filled with a list of the names of all // CSS and JavaScript files loaded in the head (that is, those // that get combined and/or minified). List<string> totalFileNames = new List<string> (); headAnalysis = new HeadAnalysis ( headHtmlSb.ToString (), totalFileNames, cs.CombineCSSFiles, cs.CombineJavaScriptFiles, cs.MinifyCSS, cs.MinifyJavaScript, urlProcessor); AddPageFilePaths (totalFileNames); CacheDependency cd = new CacheDependency (totalFileNames.ToArray ()); HttpContext.Current.Cache.Insert (headCacheKey, headAnalysis, cd); } } // ------------ // Do all replacements in the head specified in headAnalysis foreach (HeadAnalysis.Replacement r in headAnalysis.Replacements) { headHtmlSb.Replace (r.original, r.replacement); } // ------------ // Process all images in the page if needed. if (cs.RemoveWhitespace || urlProcessor.ImagesNeedProcessing ()) { ProcessAllImages (Control.Page.Controls, urlProcessor, cs.RemoveWhitespace); } // ------------ string headHtml = headHtmlSb.ToString (); // At this point, urlProcessor and headAnalysis contains all image urls. // Build the JavaScript to preload any images that need to be preloaded, // and insert it at the start of the head, just after the initial head tag. string preloadJS1 = PreloadJS (cs.PreloadAllImages, cs.PrioritizedImages, headAnalysis.ProcessedImageUrls); string preloadJS2 = PreloadJS (cs.PreloadAllImages, cs.PrioritizedImages, urlProcessor.ProcessedImageUrls); string preloadJS = preloadJS1 + preloadJS2; // If any urls need to be preloaded, insert the JavaScript block after the first > (that is, after the // head tag). if (!string.IsNullOrEmpty (preloadJS)) { headHtml = InsertedAfterFirstTag (headHtml, preloadJS); } writer.Write (headHtml); }
private void ProcessAllImages (ControlCollection cc, UrlProcessor urlProcessor, bool removeWhitespace) { bool imagesNeedProcessing = urlProcessor.ImagesNeedProcessing (); foreach (Control c in cc) { if (c is LiteralControl) { LiteralControl lit = (LiteralControl)c; string literalContent = lit.Text; string newLiteralContent = literalContent; if (imagesNeedProcessing) { // The "src" group in this regexp doesn't just contain the image url, but also the src= and the quotes. // That allows us to replace the entire src="...", instead of the url. // If you only replace the old url with the new url, than if you have a tag with url "images/ball3.png" after a tag with "/images/ball3.png" // when the second url ("images/ball3.png") gets replaced, it alsos replace part of the first tag "/images/ball3.png" (because the first tag // contains the second tag). const string regexpImgGroup = @"<img[^>]*?(?<src>src[^=]*?=[^""']*?(?:""|')(?<url>[^""']*?)(?:""|'))[^>]*?>"; Regex r = new Regex (regexpImgGroup, RegexOptions.IgnoreCase); Match m = r.Match (literalContent); while (m.Success) { string oldSrc = m.Groups ["src"].Value; string oldUrl = m.Groups ["url"].Value; string newUrl = urlProcessor.ProcessedUrl (oldUrl, true, false, Control.Page.Request.Url, null); string newSrc = @"src=""" + newUrl + @""""; newLiteralContent = newLiteralContent.Replace (oldSrc, newSrc); m = m.NextMatch (); } } if (removeWhitespace) { newLiteralContent = CollapsedWhitespace (newLiteralContent); } lit.Text = newLiteralContent; } else if ((c is HtmlImage) && imagesNeedProcessing) { HtmlImage hi = (HtmlImage)c; string versionId = LastUpdateTimeImageControl (hi.Src, hi, urlProcessor.ThrowExceptionOnMissingFile); hi.Src = urlProcessor.ProcessedUrl (hi.Src, true, false, Control.Page.Request.Url, versionId); } else if ((c is HyperLink) && imagesNeedProcessing) { HyperLink hl = (HyperLink)c; string versionId = LastUpdateTimeImageControl (hl.ImageUrl, hl, urlProcessor.ThrowExceptionOnMissingFile); hl.ImageUrl = urlProcessor.ProcessedUrl (hl.ImageUrl, true, false, Control.Page.Request.Url, versionId); } else if ((c is Image) && imagesNeedProcessing) { Image i = (Image)c; string versionId = LastUpdateTimeImageControl (i.ImageUrl, i, urlProcessor.ThrowExceptionOnMissingFile); i.ImageUrl = urlProcessor.ProcessedUrl (i.ImageUrl, true, false, Control.Page.Request.Url, versionId); } else { ProcessAllImages (c.Controls, urlProcessor, removeWhitespace); } } }
/// <summary> /// Constructor /// </summary> /// <param name="headHtml"> /// the current contents of the head /// </param> /// <param name="totalFileNames"> /// totalFileNames will be filled with a list of the names of all /// CSS and JavaScript files loaded in the head (that is, those /// that get combined and/or minified). /// </param> /// <param name="combineCSSFiles"> /// If true, the CSS files in the group are combined into a single file. /// </param> /// <param name="combineJavaScriptFiles"> /// If true, the JavaScript files in the group are combined into a single file. /// </param> /// <param name="urlProcessor"> /// Use this UrlProcessor to for example insert version ids. /// The ProcessedImageUrls property of this UrlProcessor has already been loaded with the /// urls of the images on the page. /// </param> /// <returns> /// New content of the head /// </returns> public HeadAnalysis( string headHtml, List<string> totalFileNames, ConfigSection.CombineOption combineCSSFiles, ConfigSection.CombineOption combineJavaScriptFiles, bool minifyCSS, bool minifyJavaScript, UrlProcessor urlProcessor) { // Find groups of script or link tags that load a script or css file from // the local site. That is, their source does not start with // http:// or https:// // // A script tag that has code between the <script> and </script> // has inline script, so we're not interested in that either. // // The files within each group will be served up as a single file // (which only exists in cache). // By combining js and css files only within the groups, we make sure // that the order of the script files is not changed. // // An improvement would be to provide an option to // move all css link tags above the script tags, so you could // combine them all. Move them above, in case any javascript depends on the css. const string regexpScriptGroup = @"(?:<script[^>]*?src=(?:""|')(?<url>(?!http://)(?!https://)[^""']*?)(?:""|')[^>]*?>[\s\n\r]*</script>[\s\n\r]*)+"; const string tagTemplateSript = "<script type=\"text/javascript\" src=\"{0}\"></script>"; const string regexpCssGroup = @"(?:<link[^>]*?href=(?:""|')(?<url>(?!http://)(?!https://)[^""']*?\.css)(?:""|')[^>]*?>[\s\n\r]*)+"; const string tagTemplateCss = "<link rel=\"stylesheet\" type=\"text/css\" href=\"{0}\" />"; // ProcessFileType adds records to the Replacements list Replacements = new List<Replacement>(); ProcessFileType( headHtml, regexpScriptGroup, FileTypeUtilities.FileType.JavaScript, tagTemplateSript, totalFileNames, combineJavaScriptFiles, true, minifyCSS, minifyJavaScript, urlProcessor); ProcessFileType( headHtml, regexpCssGroup, FileTypeUtilities.FileType.CSS, tagTemplateCss, totalFileNames, combineCSSFiles, false, minifyCSS, minifyJavaScript, urlProcessor); // The urlProcessor now contains all image urls contained in CSS files. // Copy those urls to this.ProcessedImageUrls. ProcessedImageUrls = new List<string>(urlProcessor.ProcessedImageUrls); urlProcessor.ProcessedImageUrls.Clear(); }
/// <summary> /// /// </summary> /// <param name="headHtmlSb"></param> /// <param name="groupRegexp"></param> /// <param name="fileType"></param> /// <param name="tagTemplate"></param> /// <param name="totalFileNames"></param> /// <param name="combineFiles"></param> /// <param name="placeCombinedFilesAtEnd"> /// This is only relevant if combineFiles equals All. /// If placeCombinedFilesAtEnd is true, the tag loading the combined file /// replaces the very last file group (important if you're loading js, because it means that if any /// js is dependent on a library loaded from a CDN, all the js will load after that library. /// /// If placeCombinedFilesAtEnd is false, the tag replaces the very first file group. /// You'd use this with CSS, to get it load possibly sooner than the js. /// </param> /// <param name="urlProcessor"></param> private void ProcessFileType( string headHtml, string groupRegexp, FileTypeUtilities.FileType fileType, string tagTemplate, List<string> totalFileNames, ConfigSection.CombineOption combineFiles, bool placeCombinedFilesAtEnd, bool minifyCSS, bool minifyJavaScript, UrlProcessor urlProcessor) { List<groupInfo> allGroups = new List<groupInfo>(); List<Uri> totalFileUrlsList = new List<Uri>(); Regex r = new Regex(groupRegexp, RegexOptions.IgnoreCase); Match m = r.Match(headHtml); // Visit each group of script or link tags. Record the html of each file group // and a list of the urls in the tags in that file group in allGroups. while (m.Success) { string fileGroup = m.Value; CaptureCollection fileUrls = m.Groups["url"].Captures; // Visit the src or href of each individual script or link tag in the group, // and add to a list of urls. List<Uri> fileUrlsList = new List<Uri>(); for (int j = 0; j < fileUrls.Count; j++) { Uri fileUrl = new Uri(HttpContext.Current.Request.Url, fileUrls[j].Value); fileUrlsList.Add(fileUrl); totalFileUrlsList.Add(fileUrl); } allGroups.Add(new groupInfo() { fileGroup = fileGroup, fileUrlsList = fileUrlsList }); m = m.NextMatch(); } // Process each file group in allGroups switch (combineFiles) { case ConfigSection.CombineOption.None: // In each group, process all URLs individually into tags. // Note that CombinedFile.Url not only has the ability to combine urls, but also // to insert version info - and we still want that to be able to use far future cache expiry, // even if not combining files. // Concatenate the tags and replace the group with the concatenated tags. foreach (groupInfo g in allGroups) { StringBuilder tagsInGroup = new StringBuilder(); foreach (Uri u in g.fileUrlsList) { string versionedUrl = CombinedFile.Url( HttpContext.Current, new List<Uri>(new Uri[] { u }), fileType, minifyCSS, minifyJavaScript, urlProcessor, totalFileNames); string versionedFileTag = string.Format(tagTemplate, versionedUrl); tagsInGroup.Append(versionedFileTag); } // Be sure to trim the group before storing it (that is, remove space at the front and end). // If you don't, you may store a group with white space at either end, that then doesn't match // a group in some other file that is exactly the same, except for the white space at either end. Replacements.Add(new Replacement { original = g.fileGroup.Trim(), replacement = tagsInGroup.ToString() }); } break; case ConfigSection.CombineOption.PerGroup: // In each group, process all URLs together into a combined tag. // Replace the group with that one tag. foreach (groupInfo g in allGroups) { string combinedFileUrl = CombinedFile.Url( HttpContext.Current, g.fileUrlsList, fileType, minifyCSS, minifyJavaScript, urlProcessor, totalFileNames); string combinedFileTag = string.Format(tagTemplate, combinedFileUrl); Replacements.Add(new Replacement { original = g.fileGroup.Trim(), replacement = combinedFileTag }); } break; case ConfigSection.CombineOption.All: // Combine all urls into a single tag. Then insert that tag in the head. // Also, remove all groups. { string combinedFileUrl = CombinedFile.Url( HttpContext.Current, totalFileUrlsList, fileType, minifyCSS, minifyJavaScript, urlProcessor, totalFileNames); string combinedFileTag = string.Format(tagTemplate, combinedFileUrl); int idxFileGroupToReplace = placeCombinedFilesAtEnd ? (allGroups.Count - 1) : 0; Replacements.Add( new Replacement { original = allGroups[idxFileGroupToReplace].fileGroup.Trim(), replacement = combinedFileTag }); // Replace all file groups with empty string, except for the one // we just replaced with the tag. allGroups.RemoveAt(idxFileGroupToReplace); foreach (groupInfo g in allGroups) { Replacements.Add( new Replacement { original = g.fileGroup.Trim(), replacement = "" }); } } break; default: throw new ArgumentException("ProcessFileType - combineFiles=" + combineFiles.ToString()); } }
public void ProcessRequest(HttpContext context) { ConfigSection cs = ConfigSection.CurrentConfigSection(); // -------------- Uri url = context.Request.Url; string path = url.PathAndQuery; FileTypeUtilities.FileType fileType = FileTypeUtilities.FileTypeOfUrl(path); // -------------- context.Response.AddHeader( "Content-Type", FileTypeUtilities.FileTypeToContentType(fileType)); const int yearInSeconds = 60 * 60 * 24 * 365; int maxAge = yearInSeconds; if (context.IsDebuggingEnabled || ((!cs.InsertVersionIdInImageUrls) && FileTypeUtilities.FileTypeIsImage(fileType))) { maxAge = 0; } // -------------- if ((fileType == FileTypeUtilities.FileType.JavaScript) || (fileType == FileTypeUtilities.FileType.CSS)) { UrlProcessor urlProcessor = new UrlProcessor( cs.CookielessDomains, cs.MakeImageUrlsLowercase, cs.InsertVersionIdInImageUrls, ConfigSection.OptionIsActive(cs.EnableCookielessDomains), cs.PreloadAllImages, ConfigSection.OptionIsActive(cs.ExceptionOnMissingFile)); string content = CombinedFile.Content( context, path, cs.MinifyCSS, cs.MinifyJavaScript, urlProcessor, out fileType); context.Response.AddHeader("Cache-Control", "public,max-age=" + maxAge.ToString()); context.Response.Write(content); } else { // The file type is not JavaScript or CSS, so it is an image. // In case the image has a version id in it, deversion the path string deversionedPath = path; if (cs.InsertVersionIdInImageUrls) { bool deversioned; deversionedPath = UrlVersioner.DeversionedImageUrl(path, out deversioned); } string fileName = CombinedFile.MapPath(deversionedPath, ConfigSection.OptionIsActive(cs.ExceptionOnMissingFile)); if (!String.IsNullOrEmpty(fileName)) { context.Response.AddHeader("Cache-Control", "public,max-age=" + maxAge.ToString()); context.Response.WriteFile(fileName); } } }
/// <summary> /// Takes the urls of a series of files (taken from the src or href /// attribute of their script or link tags), and returns the url /// of the combined file. That url will be placed in /// single script or link tag that replaces the individual script or link tags. /// /// When the browser sends a request for this url, get the content /// to return by calling CombinedFileContent. /// </summary> /// <param name="context"></param> /// <param name="fileUrls"></param> /// <param name="totalFileNames"> /// The method adds the physical file names of the files making up the combined /// file to this parameter. If this is null, nothing is done. /// </param> /// <returns></returns> public static string Url( HttpContext context, List<Uri> fileUrls, FileTypeUtilities.FileType fileType, bool minifyCSS, bool minifyJavaScript, UrlProcessor urlProcessor, List<string> totalFileNames) { string urlsHash = UrlsHash(fileUrls); // Store the urls of the files, so GetContentVersion can retrieve // the urls if needed. StoreFileUrls(context, urlsHash, fileUrls, fileType); string combinedFileContent = null; string versionId = null; GetContentVersion( context, urlsHash, urlProcessor, totalFileNames, minifyCSS, minifyJavaScript, out combinedFileContent, out versionId, out fileType); string combinedFileUrl = CombinedFileUrl(urlsHash, versionId, fileType, urlProcessor); return combinedFileUrl; }
/// <summary> /// Takes the content of a CSS file and the original absolute url of that /// file, and changes all url() properties to absolute urls. /// This way, if the CSS file has been combined with other files, the /// images specified in the url() properties will still show. /// </summary> /// <param name="fileContent"></param> /// <param name="fileUrl"></param> private static void FixUrlProperties( ref string fileContent, Uri fileUrl, UrlProcessor urlProcessor) { StringBuilder fileContentSb = new StringBuilder(fileContent); const string regexpUrlProperty = @"url\([\s\n\r]*(?<url>(?!http://)(?!https://)[^)]*?)[\s\n\r]*\)"; Regex r = new Regex(regexpUrlProperty, RegexOptions.IgnoreCase); Match m = r.Match(fileContent); // Visit each url property while (m.Success) { string urlProperty = m.Value; CaptureCollection urlProperties = m.Groups["url"].Captures; if (urlProperties.Count > 0) { string relativeUrl = urlProperties[0].Value; string absoluteUrl = urlProcessor.ProcessedUrl(relativeUrl, true, true, fileUrl, null); fileContentSb.Replace(urlProperty, "url(" + absoluteUrl + ")"); } m = m.NextMatch(); } fileContent = fileContentSb.ToString(); }
/// <summary> /// Takes the hash identifying the urls of the files that make up a combined file. /// Returns the compressed content of the combined files, and the version ID /// of the combined files. The version ID is based on the last modified time of the last /// modified file file that goes into the combined file. /// </summary> /// <param name="context"></param> /// <param name="urlsHash"></param> /// <param name="totalFileNames"> /// The file names of the files read by this method get added to this list. /// If this is null, nothing is done with this parameter. /// </param> /// <param name="combinedFileContent"> /// Content to be sent back to the browser. /// Will be null if the content could not be retrieved, because the hash was not found in /// the Application object. This means that the file tag that caused the browser to /// request this file was generated in JavaScript or appeared outside the head tag /// on the page. This will also happen in debug mode. /// In this case, the name of the requested file matches an actual /// file on the server. /// </param> /// <param name="versionId"></param> private static void GetContentVersion( HttpContext context, string urlsHash, UrlProcessor urlProcessor, List<string> totalFileNames, bool minifyCSS, bool minifyJavaScript, out string combinedFileContent, out string versionId, out FileTypeUtilities.FileType fileType) { combinedFileContent = null; versionId = null; List<Uri> fileUrls; RetrieveFileUrls(context, urlsHash, out fileUrls, out fileType); if (fileUrls == null) { return; } CacheElement cacheElement = (CacheElement)context.Cache[urlsHash]; if (cacheElement == null) { StringBuilder combinedContentSb = new StringBuilder(); DateTime mostRecentModifiedTime = DateTime.MinValue; List<string> fileNames = new List<string>(); bool fileMissing = false; foreach (Uri fileUrl in fileUrls) { string filePath = MapPath(fileUrl.AbsolutePath, urlProcessor.ThrowExceptionOnMissingFile); string fileContent = null; if (filePath != null) { fileContent = File.ReadAllText(filePath); if (fileType == FileTypeUtilities.FileType.CSS) { FixUrlProperties(ref fileContent, fileUrl, urlProcessor); } DateTime lastModifiedTime = File.GetLastWriteTime(filePath); mostRecentModifiedTime = (mostRecentModifiedTime > lastModifiedTime) ? mostRecentModifiedTime : lastModifiedTime; fileNames.Add(filePath); if (totalFileNames != null) { totalFileNames.Add(filePath); } } else { // A comment starting with /*! doesn't get removed by the minifier fileContent = string.Format("\n/*!\n** Does not exist: {0}\n*/\n", fileUrl); fileMissing = true; } combinedContentSb.Append(fileContent); } string combinedContent = combinedContentSb.ToString(); if (!string.IsNullOrEmpty(combinedContent)) { cacheElement = new CacheElement(); cacheElement.CombinedFileContent = combinedContent; if (fileType == FileTypeUtilities.FileType.JavaScript) { if (minifyJavaScript) { cacheElement.CombinedFileContent = JavaScriptCompressor.Compress(combinedContent); } } else { if (minifyCSS) { cacheElement.CombinedFileContent = CssCompressor.Compress(combinedContent); } } cacheElement.VersionId = VersionId(mostRecentModifiedTime); // Cache the newly created cacheElement // // Do not cache the cacheElement if one of the files couldn't be found. // That way, the package will keep checking the missing file, and pick it up // when someone puts the file there. if (!fileMissing) { CacheDependency cd = new CacheDependency(fileNames.ToArray()); context.Cache.Insert(urlsHash, cacheElement, cd); } } } if (cacheElement == null) { if (context.IsDebuggingEnabled) { throw new Exception("cacheElement == null"); } combinedFileContent = ""; versionId = ""; } else { combinedFileContent = cacheElement.CombinedFileContent; versionId = cacheElement.VersionId; } }
/// <summary> /// Returns a combined file url. /// </summary> /// <param name="urlsHash"> /// Hash based on the urls of the files that make up the combined file. /// </param> /// <param name="versionId"> /// A string that is different for each version of the files that make up /// the combined file. This is used to make sure that a browser doesn't /// pick up an outdated version from its internal browser cache. /// </param> /// <param name="fileType"> /// </param> /// <param name="urlDomain"> /// Domain to be used for the url. /// Make null or empty if you don't want a domain used in the url. /// </param> /// <returns></returns> private static string CombinedFileUrl( string urlsHash, string versionId, FileTypeUtilities.FileType fileType, UrlProcessor urlProcessor) { string url = "/" + urlsHash + FileTypeUtilities.FileTypeToExtension(fileType); return urlProcessor.ProcessedUrl(url, false, false, null, versionId); }