├── test ├── images │ ├── test.jpg │ └── largerFileSizeThanSource.jpg ├── index.cfm ├── application.cfc └── suite.cfc ├── LICENSE ├── CHANGELOG.md ├── README.md └── adaptiveImages.cfc /test/images/test.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cfsimplicity/AdaptiveImages/HEAD/test/images/test.jpg -------------------------------------------------------------------------------- /test/images/largerFileSizeThanSource.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cfsimplicity/AdaptiveImages/HEAD/test/images/largerFileSizeThanSource.jpg -------------------------------------------------------------------------------- /test/index.cfm: -------------------------------------------------------------------------------- 1 | 2 | paths = [ "root.test.suite" ]; 3 | try{ 4 | testRunner = New testbox.system.TestBox( paths ); 5 | WriteOutput( testRunner.run() ); 6 | } 7 | catch( any exception ){ 8 | WriteDump( exception ); 9 | } 10 | -------------------------------------------------------------------------------- /test/application.cfc: -------------------------------------------------------------------------------- 1 | component{ 2 | this.name = "AdaptiveImagesTests"; 3 | this.sessionManagement = false; 4 | this.applicationTimeout = CreateTimeSpan( 0, 0, 5, 0 ); 5 | request.relativePathToRoot = "../"; // from this directory to the topmost application directory 6 | this.mappings[ "/root" ] = GetDirectoryFromPath( GetCurrentTemplatePath() ) & request.relativePathToRoot; 7 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013-2019 Julian Halliwell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 2.1.5 - 19 December 2019 2 | - \#9 Record the originally requested image URL when logging errors 3 | 4 | ## 2.1.4 - 9 October 2019 5 | - \#8 Replace GetFileInfo() with faster FileInfo() if using Lucee 6 | 7 | ## 2.1.3 - 8 November 2017 8 | - \#7 Handle errors caused by file system being out of sync with file operations cache 9 | 10 | ## 2.1.2 - 2 October 2017 11 | - Enhancements 12 | - \#5 Add option to log errors only 13 | - Fixes 14 | - \#6 Query strings in original URL causes errors. 15 | 16 | ## 2.0.1 - 19 September 2017 17 | - Fixes 18 | - \#4 Tomcat appears not to be able to read javascript set cookies containing commas 19 | - Cached folder cleanup not working properly in tests under ACF11 20 | - Other 21 | - Add locking around file/directory deletion operations 22 | 23 | ## 2.0.0 - 8 September 2017 24 | - Enhancements 25 | - \#2 Add `cacheFolderName` config option 26 | - Fixes 27 | - \#3 `cleanupCacheFolders()` should delete any empty resolution cache folders 28 | - Breaking changes 29 | - Remove support for ColdFusion 10 and below, and Railo. 30 | - Change to positional init arguments with new `cacheFolderName` option 31 | - Other 32 | - Rewrite tests using TestBox BDD style 33 | 34 | ## 1.0.2 - 12 February 2015 35 | - Replace MX Unit with Testbox in MX Unit style 36 | 37 | ## 1.0.0 - 27 November 2013 38 | - Initial release -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Adaptive Images 2 | This is a CFML version of Matt Wilcox's [Adaptive Images](http://adaptive-images.com/), a server-side solution to automatically create, cache, and deliver device-appropriate versions of your website’s images. 3 | 4 | If your site's design is "responsive", so that images are not given a fixed width or height but scaled to the width of their container, you can save bandwidth and speed up client load times by using AdaptiveImages to ensure end users do not have to download images intended for the widest screens. Instead, smaller versions will be created and served as necessary, according to the detected resolution of the device. 5 | 6 | [More background](http://cfsimplicity.com/73/the-simplicity-of-adaptive-images) 7 | 8 | ## Acknowledgements 9 | In addition to [Matt's PHP code](https://github.com/MattWilcox/Adaptive-Images), I also took inspiration and code from [Raymond Camden's ColdFusion fork](https://github.com/cfjedimaster/Adaptive-Images) 10 | 11 | ### Differences 12 | However, this is not a direct port of either project. It places more emphasis on performance through: 13 | 14 | - in-memory caching of file path and existence tests to minimise disk access; 15 | - assuming the source file existence check has been handled by the web server rewrite engine; 16 | - checking that the bytesize of the resized file is no larger than the original (sometimes downscaling an image can actually increase its file size); 17 | 18 | There are additional file and memory cache maintenance functions to ensure they don't become stale. 19 | 20 | ## Requirements 21 | - Lucee Server 4.5 or later 22 | - Adobe ColdFusion 11 or later 23 | - Web server URL rewriting 24 | 25 | ## Usage 26 | 27 | 1) Create an instance of AdaptiveImages in the onApplicationStart() method of your Application.cfc, specifying the resolutions you want to support (use your web analytics to determine the most common device widths). 28 | 29 | ``` 30 | application.adaptiveImages = New adaptiveImages( resolutions: [ 320, 480, 768, 1024, 1400, 1680 ] ); 31 | ``` 32 | 33 | 2) Create a ColdFusion template in your webroot to invoke the AdaptiveImages component and pass image requests to it. 34 | 35 | ***adaptiveImages.cfm*** 36 | ``` 37 | 38 | try{ 39 | application.adaptiveImages.process( cgi.HTTP_X_ORIGINAL_URL ); 40 | } 41 | catch( any exception ){ 42 | abort; 43 | } 44 | 45 | ``` 46 | 47 | (Note: `cgi.HTTP_X_ORIGINAL_URL` is the variable made available in ColdFusion by IIS7+. If using a different web server, it will have its own cgi scoped key name for the originally requested URL). 48 | 49 | 3) Add a rule to your web server's Rewrite Engine to intercept requests for image files and pass them to the CF template. Your rule should define which images you want AdaptiveImages to handle. Here's an example in **IIS7+** format: 50 | 51 | ``` 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | ``` 62 | 63 | 4) Add the following javascript to the HTML `` of all of your web pages to detect and store the client device's resolution/pixel density. 64 | ``` 65 | 68 | ``` 69 | If you are using HTTPS then you should add the `secure` attribute: 70 | ``` 71 | 74 | ``` 75 | Note: unlike the orginal Adaptive Images implementation, the screen width and pixel ration values are separated by a **hyphen** not a comma, since it appears that cookie values set by javascript and containing commas are not always read by the server. 76 | 77 | ## Configuration options 78 | You can pass these arguments when instantiating AdaptiveImages.cfc: 79 | - `resolutions` *required*. An array of the device widths you wish to support, in pixels and in any order. 80 | - `cacheFileOperations` boolean: default=true. Whether to cache source file paths and file existence tests to avoid unnecessary disk access. You will normally want to keep this enabled unless your source files change very frequently and you are not using the cache maintenance functions, or you are memory-constrained and have a lot of files (but note that only the *paths* are stored, not the images themselves). 81 | - `checkForFileUpdates` boolean: default=false. Ensure updated source images are re-cached (requires disk access on every request). 82 | - `cacheFolderName` string: default="". Store the resized images in a sub-folder with this name 83 | - `browserCacheSeconds` integer: default=2592000 (30 days). Number of seconds the *browser* cache should last. 84 | - `pixelDensityMultiplier` number between 1 and 3: default=1.5. By how much to multiply the resolution for "retina" displays as detected by the resolution cookie. 85 | - `jpgQuality` number between 1 and 100: default=50. The quality of resized JPGs. 86 | - `sharpen` boolean: default=true. Shrinking images can blur details. Perform a sharpen on re-scaled images? 87 | - `interpolation` string: default="highPerformance". Interpolation algorithm to use when scaling/resizing file (depending on whether performance or quality is paramount). 88 | - `writeLogs` boolean: default=false. Whether or not to log activity - don't use in production. 89 | - `logFilename`: string: default="adaptive-images". If logging, the name of the file. 90 | - `logErrors`: boolean: default=[writeLogs value]. If `writeLogs` is false, allow just errors in processing to be logged. 91 | 92 | ## Updating source files 93 | For best performance keep the `checkForFileUpdates` option disabled. If you need to update or delete a source image, call the `deleteCachedCopies( fullImagePath )` method from your update/delete code passing in the full path of the image file. 94 | 95 | ## Housekeeping 96 | Use the `cleanupCacheFolders( sourceImageFolder )` method periodically to remove any cached files where the source image no longer exists. Currently this function is not recursive, so needs to be applied separately to each parent source image folder containing cached image folders. 97 | 98 | ## Test Suite 99 | Tests require [TestBox 2.1](https://github.com/Ortus-Solutions/TestBox) or later. You will need to create an application mapping for `/testbox`. 100 | 101 | ## Legal 102 | The original Adaptive Images by Matt Wilcox is licensed under a [Creative Commons Attribution 3.0 Unported License](http://creativecommons.org/licenses/by/3.0/) 103 | 104 | This port is licensed under an MIT license 105 | 106 | ### The MIT License (MIT) 107 | 108 | Copyright (c) 2013-2019 Julian Halliwell 109 | 110 | Permission is hereby granted, free of charge, to any person obtaining a copy of 111 | this software and associated documentation files (the "Software"), to deal in 112 | the Software without restriction, including without limitation the rights to 113 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 114 | the Software, and to permit persons to whom the Software is furnished to do so, 115 | subject to the following conditions: 116 | 117 | The above copyright notice and this permission notice shall be included in all 118 | copies or substantial portions of the Software. 119 | 120 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 121 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 122 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 123 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 124 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 125 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 126 | -------------------------------------------------------------------------------- /adaptiveImages.cfc: -------------------------------------------------------------------------------- 1 | component{ 2 | 3 | variables.version = "2.1.5"; 4 | variables.isACF = ( server.coldfusion.productname IS "ColdFusion Server" ); 5 | variables.isLucee = ( server.coldfusion.productname IS "Lucee" ); 6 | 7 | function init( 8 | required array resolutions // the resolution break-points to use (screen widths, in pixels, any order you like) 9 | ,boolean cacheFileOperations = true // cache source file paths and file existence tests to avoid unnecessary disk access 10 | ,boolean checkForFileUpdates = false // ensures updated source images are re-cached, but requires disk access on every request 11 | ,string cacheFolderName = "" // sub-folder in which to store the resized images 12 | ,numeric browserCacheSeconds = 2592000 // how long the BROWSER cache should last (30 days by default) 13 | ,numeric pixelDensityMultiplier = 1.5 // by how much to multiply the resolution for "retina" displays. Number between 1 and 3 14 | ,numeric jpgQuality = 50 // the quality of any generated JPGs on a scale of 1 to 100 15 | ,boolean sharpen = true // Shrinking images can blur details, perform a sharpen on re-scaled images? 16 | ,string interpolation = "highPerformance" // interpolation algorithm to use when scaling/resizing file 17 | ,boolean writeLogs = false // whether or not to log activity - don't use in production 18 | ,string logFilename = "adaptive-images" // name of logfile 19 | ,boolean logErrors = arguments.writeLogs //with writeLogs false, just log errors from the process() method 20 | ) 21 | { 22 | variables.config = arguments; 23 | config.cacheFolderName = Trim( cacheFolderName ); //ensure it's a string 24 | variables.hasCacheFolderName = config.cacheFolderName.Len(); 25 | validateConfig( config ); 26 | ArraySort( config.resolutions, "numeric", "asc" );// smallest to largest 27 | config.smallestResolution = config.resolutions[ 1 ]; 28 | config.largestResolution = config.resolutions[ ArrayLen( config.resolutions ) ]; 29 | variables.fileOperationsCache = {}; 30 | return this; 31 | } 32 | 33 | /* The main public method to serve images */ 34 | /* Pass in the original requested URL as supplied by the URL Rewrite engine. For IIS this is cgi.HTTP_X_ORIGINAL_URL */ 35 | public function process( required string originalUrl ){ 36 | try{ 37 | var requestedFileUri = cleanupUrl( originalUrl ); 38 | var requestedFilename = ListLast( requestedFileUri, "/" ); 39 | var sourceFilePath = getSourceFilePath( requestedFileUri ); 40 | var sourceFolderPath = GetDirectoryFromPath( sourceFilePath ); 41 | var requestedFileExtension = ListLast( requestedFilename, "." ); 42 | _log( "AI: Request for: #requestedFileUri# which translates to #sourceFilePath#" ); 43 | var mimeType = mimeType( requestedFileExtension ); 44 | var resolution = resolution(); 45 | if( resolution GT config.largestResolution ){ 46 | _log( "AI: Client resolution #resolution# is larger than largest configured resolution, so sending original" ); 47 | return sendImage( sourceFilePath, mimeType ); 48 | } 49 | var resolutionFolderName = resolutionFolderName( resolution ); 50 | _log( "AI: Resolution set=#resolution#" ); 51 | var cacheFolderPath = sourceFolderPath & resolutionFolderName & "/"; 52 | var cachedFilePath = cacheFolderPath & requestedFilename; 53 | _log( "AI: Checking for cached file: #cachedFilePath#" ); 54 | if( cachedFileExists( cachedFilePath ) ){ 55 | if( !config.checkForFileUpdates ){ 56 | _log( "AI: Sending cached file without checking for an updated source" ); 57 | return sendImage( cachedFilePath, mimeType ); 58 | } 59 | else if( !fileHasBeenUpdated( sourceFilePath, cachedFilePath ) ){ 60 | _log( "AI: Sending cached file as source has not been updated" ); 61 | return sendImage( cachedFilePath, mimeType ); 62 | } 63 | } 64 | // not in cache, or has been updated, so continue 65 | var sourceImage = ImageRead( sourceFilePath ); 66 | if( sourceImage.width LTE resolution ){ 67 | // No need to downscale because the width of the source image is already less than the client width 68 | _log( "AI: Source width #sourceImage.width# is the same size or smaller than client resolution #resolution#" ); 69 | return sendImage( sourceFilePath, mimeType ); 70 | } 71 | var newImage = generateImage( sourceImage, resolution ); 72 | ensureCacheFolderExists( cacheFolderPath ); 73 | // save the new file in the appropriate path, and send a version to the browser 74 | ImageWrite( newImage, cachedFilePath, config.jpgQuality / 100 ); 75 | checkCachedImageIsNotLargerThanSource( cachedFilePath, sourceFilePath ); 76 | // send image to client 77 | return sendImage( cachedFilePath, mimeType ); 78 | } 79 | catch( any exception ){ 80 | if( config.logErrors OR config.writeLogs ){ 81 | var errorLogText = "AI Error"; 82 | if( arguments.KeyExists( "originalUrl" ) ) 83 | errorLogText &= " serving #arguments.originalUrl#"; 84 | errorLogText &= ": "; 85 | } 86 | if( !DirectoryExists( cacheFolderPath ) OR !FileExists( cachedFilePath ) ){ 87 | if( config.logErrors OR config.writeLogs ) 88 | WriteLog( file: config.logFilename, text: errorLogText & "cached image should exist according to FO cache but is missing. Clearing FO cache." ); 89 | clearFileOperationsCache(); 90 | } 91 | else { 92 | if( config.logErrors OR config.writeLogs ) 93 | WriteLog( file: config.logFilename, text: errorLogText & exception.message ); 94 | cfheader( statuscode: "503", statustext: "Temporary problem" ); 95 | abort; 96 | } 97 | } 98 | } 99 | 100 | /* Inspect properties */ 101 | public struct function getFileOperationsCache(){ 102 | return fileOperationsCache; 103 | } 104 | 105 | public struct function getConfig(){ 106 | return config; 107 | } 108 | 109 | /* Public Maintenance functions */ 110 | public void function clearFileOperationsCache(){ 111 | if( !config.cacheFileOperations ) 112 | return; 113 | StructClear( fileOperationsCache ); 114 | } 115 | 116 | /* I delete any cached copies for the specified source image - use me when deleting or updating a source image */ 117 | public void function deleteCachedCopies( required string imageFullPath ){ 118 | if( !FileExists( imageFullPath ) ) 119 | return; 120 | imageFullPath = forwardSlashes( imageFullPath ); 121 | var sourceFolderPath = GetDirectoryFromPath( imageFullPath ); 122 | for( var resolution in config.resolutions ){ 123 | var cachedFile = sourceFolderPath & resolutionFolderName( resolution ) & "/" & GetFileFromPath( imageFullPath ); 124 | if( FileExists( cachedFile ) ){ 125 | lock name=cachedFile timeout=5 { 126 | FileDelete( cachedFile ); 127 | } 128 | } 129 | } 130 | clearFileOperationsCache(); 131 | } 132 | 133 | /* I delete any cached images where the source no longer exists */ 134 | public void function cleanupCacheFolders( required string sourceImageFolder ){ 135 | var sourceFolderPath = ExpandPath( sourceImageFolder ); 136 | var sourceFiles = DirectoryList( sourceFolderPath, false, "name" ); 137 | var cachedImages = []; 138 | for( var resolution in config.resolutions ){ 139 | var resolutionFolderPath = sourceFolderPath & resolutionFolderName( resolution ) & "/"; 140 | if( !DirectoryExists( resolutionFolderPath ) ) 141 | continue; 142 | cachedImages = DirectoryList( resolutionFolderPath, false, "name" ); 143 | if( ArrayLen( cachedImages ) ){ 144 | for( var image in cachedImages ){ 145 | if( !ArrayFindNoCase( sourceFiles, image ) ){ 146 | var imagePath = resolutionFolderPath & image; 147 | lock name=imagePath timeout=5 { 148 | FileDelete( imagePath ); 149 | } 150 | } 151 | } 152 | // See if there are any images left 153 | cachedImages = DirectoryList( resolutionFolderPath, false, "name" ); 154 | } 155 | // Delete empty resolution folders 156 | if( !ArrayLen( cachedImages ) ){ 157 | lock name=resolutionFolderPath timeout=5 { 158 | DirectoryDelete( resolutionFolderPath ); 159 | } 160 | } 161 | } 162 | } 163 | 164 | /* Private helper functions */ 165 | 166 | private void function validateConfig( required struct config ){ 167 | var exceptionType = "AdaptiveImages.invalidConfiguration"; 168 | if( !ArrayLen( config.resolutions ) ) 169 | throw( type: exceptionType,message: "At least one resolution must be specified" ); 170 | if( !IsValid( "integer", config.browserCacheSeconds ) ) 171 | throw( type: exceptionType, message: "The browserCacheSeconds argument must be an integer" ); 172 | if( !IsValid( "range", config.pixelDensityMultiplier, 1, 3 ) ) 173 | throw( type: exceptionType, message: "The pixelDensityMultiplier argument must be between 1 and 3" ); 174 | if( !IsValid( "range", config.jpgQuality, 1, 100 ) ) 175 | throw( type: exceptionType, message: "The pixelDensityMultiplier argument must be between 1 and 100" ); 176 | } 177 | 178 | private void function checkCachedImageIsNotLargerThanSource( required string cachedFilePath, required string sourceFilePath ){ 179 | cachedFileSize = _GetFileInfo( cachedFilePath ).size; 180 | sourceFileSize = _GetFileInfo( sourceFilePath ).size; 181 | if( cachedFileSize GT sourceFileSize ){ 182 | _log( "AI: Scaled image is #( cachedFileSize - sourceFileSize )# bytes larger than the original. Copying original instead." ); 183 | FileCopy( sourceFilePath, cachedFilePath ); 184 | } 185 | } 186 | 187 | /* Resize the source image to the width of the resolution breakpoint we're working with */ 188 | private function generateImage( required sourceImage, required numeric resolution ){ 189 | var newImage = sourceImage; 190 | ImageScaleToFit( newImage, resolution, "", config.interpolation );// height as empty string will cause aspect ratio to be maintained 191 | if( config.sharpen ) 192 | ImageSharpen( newImage ); 193 | return newImage; 194 | } 195 | 196 | private boolean function isMobile( required string userAgent = cgi.HTTP_USER_AGENT ){ 197 | return FindNoCase( "mobile", userAgent ); 198 | } 199 | 200 | private boolean function cookieIsValid(){ 201 | return REFind( "^[0-9]+[,-][0-9\.]+$", cookie.resolution ); 202 | } 203 | 204 | /* Send different defaults to mobile and desktop */ 205 | private numeric function defaultResolution(){ 206 | return isMobile()? config.smallestResolution: config.largestResolution; 207 | } 208 | 209 | private numeric function resolution(){ 210 | if( IsNull( cookie.resolution ) ){ 211 | _log( "AI: Cookie not found" ); 212 | return defaultResolution(); 213 | } 214 | if( !cookieIsValid() ){ 215 | _log( "AI: Invalid cookie deleted" ); 216 | deleteCookie(); 217 | return defaultResolution(); 218 | } 219 | _log( "AI: Cookie.resolution=#cookie.resolution#" ); 220 | var cookieData = ListToArray( cookie.resolution, ",-" );// Hyphen more reliable, but allow either separator 221 | var clientWidth = cookieData[ 1 ]; 222 | var clientPixelDensity = cookieData[ 2 ]; 223 | var maxImageWidth = clientWidth; 224 | // if pixel density greater than 1, then we need to be smart about adapting and fitting into the defined breakpoints 225 | if( clientPixelDensity GT 1 ) 226 | maxImageWidth = ( clientWidth * config.pixelDensityMultiplier ); 227 | _log( "AI: maxImageWidth=#maxImageWidth#" ); 228 | // actual resolution is bigger than largest defined resolution 229 | if( maxImageWidth GT config.largestResolution ) 230 | return maxImageWidth; 231 | // otherwise return the matching or next highest defined breakpoint 232 | for( var thisResolution in config.resolutions){ 233 | if( maxImageWidth LTE thisResolution ) 234 | return thisResolution; 235 | } 236 | // fallback, should never run 237 | return config.largestResolution; 238 | } 239 | 240 | private string function resolutionFolderName( required numeric resolution ){ 241 | if( !hasCacheFolderName ) 242 | return resolution; 243 | return config.cacheFolderName & "/" & resolution; 244 | } 245 | 246 | /* File/folder functions */ 247 | 248 | // Always use forward slashes for consistency 249 | private string function forwardSlashes( required string path ){ 250 | return path.Replace( "\", "/", "ALL" ); 251 | } 252 | 253 | private string function cleanupUrl( required string originalUrl ){ 254 | // remove any query string 255 | return ListFirst( UrlDecode( originalUrl ), "?" ); 256 | } 257 | 258 | private string function getSourceFilePath( required string fileUri ){ 259 | var filePath = forwardSlashes( ExpandPath( fileUri ) ); 260 | if( !config.cacheFileOperations ) 261 | return filePath; 262 | var cacheKey = fileUri.REReplace( "^/", "" );// CF vars can't begin with a slash 263 | if( StructKeyExists( fileOperationsCache, cacheKey ) ){ 264 | _log( "AI: Using cached source file path" ); 265 | return fileOperationsCache[ cacheKey ].path; 266 | } 267 | fileOperationsCache[ cacheKey ] = { path: filePath }; 268 | return filePath; 269 | } 270 | 271 | private void function cachePathExistence( required string path ){ 272 | if( !config.cacheFileOperations ) 273 | return; 274 | fileOperationsCache[ path ] = true; 275 | _log( "AI: Caching existence flag for #path#" ); 276 | } 277 | 278 | private boolean function cacheFolderExists( required string path ){ 279 | if( !config.cacheFileOperations ) 280 | return DirectoryExists( path ); 281 | var cacheKey = path; 282 | if( fileOperationsCache.KeyExists( cacheKey ) ){ 283 | _log( "AI: Using cached existence test for resolution cache folder" ); 284 | return true; 285 | } 286 | var pathExists = DirectoryExists( path ); 287 | if( pathExists ) 288 | cachePathExistence( path ); 289 | return pathExists; 290 | } 291 | 292 | private void function ensureCacheFolderExists( required string cacheFolderPath ){ 293 | _log( "AI: Does #cacheFolderPath# exist? #cacheFolderExists( cacheFolderPath )#" ); 294 | if( !cacheFolderExists( cacheFolderPath ) ){ 295 | DirectoryCreate( cacheFolderPath ); 296 | _log( "AI: Created #cacheFolderPath#" ); 297 | cachePathExistence( cacheFolderPath ); 298 | } 299 | } 300 | 301 | private boolean function cachedFileExists( required string path ){ 302 | if( !config.cacheFileOperations ) 303 | return FileExists( path ); 304 | if( fileOperationsCache.KeyExists( path ) ){ 305 | _log( "AI: Using cached existence test for cached path" ); 306 | return true; 307 | } 308 | var pathExists = FileExists( path ); 309 | if( pathExists ) 310 | cachePathExistence( path ); 311 | return pathExists; 312 | } 313 | 314 | //This check is expensive: disabled by default, but use if images change frequently and you are not using deleteCachedCopies() when performing updates 315 | private boolean function fileHasBeenUpdated( required string sourceFilePath, required string cachedFilePath ){ 316 | // get last modified of cached file 317 | var cacheDate = _GetFileInfo( cachedFilePath ).lastModified; 318 | // get last modified of original 319 | var sourceDate = _GetFileInfo( sourceFilePath ).lastModified; 320 | _log( "AI: Checking for source updates: Cached file modified: #cacheDate#, source file modified: #sourceDate#" ); 321 | return ( cacheDate LT sourceDate ); 322 | } 323 | 324 | /* END file/folder functions */ 325 | 326 | private string function mimeType( required string requestedFileExtension ){ 327 | switch( requestedFileExtension ){ 328 | case "png": 329 | return "image/png"; 330 | case "gif": 331 | return "image/gif"; 332 | case "jpg": case "jpeg": case "jpe": 333 | return "image/jpeg"; 334 | } 335 | throw( type: "AdaptiveImages.invalidFileExtension", message: "The file requested has an invalid file extension: '#requestedFileExtension#'." ); 336 | } 337 | 338 | private void function _log( required string text, string file = config.logFilename ){ 339 | if( config.writeLogs ) 340 | WriteLog( file: "#file#", text: "#text#" ); 341 | } 342 | 343 | private void function sendImage( required string filepath, required string mimeType, browserCacheSeconds = config.browserCacheSeconds ){ 344 | cfheader( name: "Content-type", value: mimeType ); 345 | if( IsNumeric( browserCacheSeconds ) ) 346 | cfheader( name: "Cache-Control", value: "private,max-age=#browserCacheSeconds#" ); 347 | var fileInfo = _GetFileInfo( filepath ); 348 | cfheader( name: "Content-Length", value: fileInfo.size ); 349 | cfcontent( file: filepath, type: mimeType ); 350 | abort; 351 | } 352 | 353 | private void function deleteCookie(){ 354 | cfcookie( name: "resolution", value: "deleted", expires: "now" ); //Change value to make testable 355 | } 356 | 357 | private struct function _GetFileInfo( required string path ){ 358 | if( !isLucee ) 359 | return GetFileInfo( arguments.path ); 360 | var result = FileInfo( arguments.path ); 361 | // support GetFileInfo().lastmodified 362 | result.lastmodified = result.dateLastModified; 363 | return result; 364 | } 365 | 366 | } -------------------------------------------------------------------------------- /test/suite.cfc: -------------------------------------------------------------------------------- 1 | component extends="testbox.system.BaseSpec"{ 2 | /* 3 | Requirements for running test suite: 4 | TestBox 2.1+ https://github.com/Ortus-Solutions/TestBox 5 | Application mapping for /testbox 6 | Web server directory (virtual or real) for /adaptiveImages 7 | */ 8 | 9 | function beforeAll(){ 10 | variables.imageFolderUrl = "/root/test/images/"; 11 | variables.imageFolderPath = forwardSlashes( ExpandPath( imageFolderUrl ) ); 12 | variables.imageFilename = "test.jpg"; 13 | variables.sourceImageUrl = imageFolderUrl & imageFilename; 14 | variables.sourceImagePath = forwardSlashes( ExpandPath( sourceImageUrl ) ); 15 | variables.sourceImageUrlCacheKey = REReplace( sourceImageUrl, "^/", "" ); 16 | } 17 | 18 | function afterAll(){ 19 | deleteResolutionCookie(); 20 | } 21 | 22 | function run( testResults, testBox ){ 23 | 24 | describe( "adaptiveImages test suite",function() { 25 | 26 | beforeEach( function( currentSpec ) { 27 | variables.ai = New root.adaptiveImages( [ "480","320" ] ); 28 | prepareMock( ai ); 29 | }); 30 | 31 | describe( "on init",function(){ 32 | 33 | it( "throws an exception if no resolutions are defined", function() { 34 | expect( function(){ 35 | ai = New root.adaptiveImages( [] ); 36 | }).toThrow( type: "AdaptiveImages.invalidConfiguration" ); 37 | } ); 38 | 39 | it( "throws an exception if browserCacheSeconds is not an integer", function() { 40 | expect( function(){ 41 | ai = New root.adaptiveImages( resolutions: [ "480" ], browserCacheSeconds: 5.1 ); 42 | }).toThrow( type: "AdaptiveImages.invalidConfiguration" ); 43 | } ); 44 | 45 | it( "throws an exception if pixelDensityMultiplier is invalid", function() { 46 | expect( function(){ 47 | ai = New root.adaptiveImages( resolutions: [ "480" ], pixelDensityMultiplier: 0 ); 48 | }).toThrow( type: "AdaptiveImages.invalidConfiguration" ); 49 | } ); 50 | 51 | it( "throws an exception if jpegQuality is invalid", function() { 52 | expect( function(){ 53 | ai = New root.adaptiveImages( resolutions: [ "480" ], jpgQuality: 0 ); 54 | }).toThrow( type: "AdaptiveImages.invalidConfiguration" ); 55 | } ); 56 | 57 | it( "sorts resolutions smallest first", function(){ 58 | var config = ai.getConfig(); 59 | expect( config.smallestResolution ).toBeLT( config.largestResolution ); 60 | }); 61 | 62 | }); 63 | 64 | describe( "getSourceFilePath", function(){ 65 | 66 | beforeEach( function(){ 67 | makePublic( ai, "getSourceFilePath" ); 68 | }); 69 | 70 | it( "returns the path from a valid source image url", function() { 71 | expect( ai.getSourceFilePath( sourceImageUrl ) ).toBe( imageFolderPath & "test.jpg" ); 72 | } ); 73 | 74 | it( "caches path by default", function() { 75 | var cache = ai.getFileOperationsCache(); 76 | var sourceFilePath = ai.getSourceFilePath( sourceImageUrl ); 77 | expect( cache ).toHaveKey( sourceImageUrlCacheKey ); 78 | expect( cache[ sourceImageUrlCacheKey ].path ).toBe( imageFolderPath & "test.jpg" ); 79 | } ); 80 | 81 | }); 82 | 83 | it( "handles gif, jpeg and png", function() { 84 | makePublic( ai, "mimeType" ); 85 | expect( ai.mimeType( "gif" ) ).toBe( "image/gif" ); 86 | expect( ai.mimeType( "jpe" ) ).toBe( "image/jpeg" ); 87 | expect( ai.mimeType( "jpg" ) ).toBe( "image/jpeg" ); 88 | expect( ai.mimeType( "png" ) ).toBe( "image/png" ); 89 | } ); 90 | 91 | it( "strips off query strings from original url", function() { 92 | makePublic( ai, "cleanupUrl" ); 93 | expect( ai.cleanupUrl( "/images/test.jpg?test=1" ) ).toBe( "/images/test.jpg" ); 94 | } ); 95 | 96 | it( "throws an exception if file extension is invalid", function() { 97 | makePublic( ai, "mimeType" ); 98 | expect( function(){ 99 | ai.mimeType( ".doc" ); 100 | }).toThrow( type: "AdaptiveImages.invalidFileExtension" ); 101 | } ); 102 | 103 | it( "finds 'mobile' anywhere in user agent string", function(){ 104 | makePublic( ai, "isMobile" ); 105 | expect( ai.isMobile( "Mozilla/5.0 (Windows NT 6.1; rv:23.0) Gecko/20100101 Firefox/23.0 " ) ).toBeFalse(); 106 | expect( ai.isMobile( "Mozilla/5.0 (compatible; MSIE 9.0; Windows Phone OS 7.5; Trident/5.0; IEMobile/9.0)" ) ).toBeTrue(); 107 | }); 108 | 109 | it( "can validate the resolution cookie", function() { 110 | makePublic( ai, "cookieIsValid" ); 111 | setResolutionCookie( "480,0" ); 112 | expect( ai.cookieIsValid() ).toBeTrue(); 113 | setResolutionCookie( "480-0" ); 114 | expect( ai.cookieIsValid() ).toBeTrue(); 115 | setResolutionCookie( "abc" ); 116 | expect( ai.cookieIsValid() ).toBeFalse(); 117 | } ); 118 | 119 | it( "can delete the resolution cookie", function() { 120 | makePublic( ai, "deleteCookie" ); 121 | setResolutionCookie( "480,0" ); 122 | ai.deleteCookie(); 123 | expect( cookie.resolution ).toBe( "deleted" );// Can't test deletion on same request but deleteCookie will also change value: test that. 124 | } ); 125 | 126 | it( "defaults to the smallest value for mobile", function() { 127 | makePublic( ai, "defaultResolution" ); 128 | ai.$( method: "isMobile", returns: true ); 129 | var config = ai.getConfig(); 130 | expect( ai.defaultResolution() ).toBe( config.smallestResolution ); 131 | } ); 132 | 133 | it( "defaults to the largest value for non-mobile", function() { 134 | makePublic( ai, "defaultResolution" ); 135 | ai.$( method: "isMobile", returns: false ); 136 | var config = ai.getConfig(); 137 | expect( ai.defaultResolution() ).toBe( config.largestResolution ); 138 | } ); 139 | 140 | it( "sets resolution as detected value if result is higher than largest defined", function() { 141 | variables.ai = New root.adaptiveImages( resolutions: [ 320, 480 ], pixelDensityMultiplier: 1.5 ); 142 | makePublic( ai, "resolution" ); 143 | setResolutionCookie( "500-1" );//non-retina 144 | expect( ai.resolution() ).toBe( 500 );// client width is bigger than largest defined 145 | } ); 146 | 147 | it( "sets resolution as next largest if detected value is lower than largest defined", function() { 148 | variables.ai = New root.adaptiveImages( resolutions: [ 320, 480, 1024 ], pixelDensityMultiplier: 1.5 ); 149 | makePublic( ai, "resolution" ); 150 | setResolutionCookie( "300-1" ); 151 | expect( ai.resolution() ).toBe( 320 );// retina resolution is 450. Expect next highest defined 152 | } ); 153 | 154 | it( "sets resolution as computed value if retina detected and result is higher than largest defined", function() { 155 | variables.ai = New root.adaptiveImages( resolutions: [ 320, 480 ], pixelDensityMultiplier: 1.5 ); 156 | makePublic( ai, "resolution" ); 157 | setResolutionCookie( "500-2" );// set as retina display so multiplier is applied 158 | expect( ai.resolution() ).toBe( 750 );//multiplier is 1.5 so 500x1.5. Bigger than largest defined 159 | } ); 160 | 161 | it( "sets resolution as next largest if retina detected and computed value is lower than largest defined", function() { 162 | variables.ai = New root.adaptiveImages( resolutions: [ 320, 480, 1024 ], pixelDensityMultiplier: 1.5 ); 163 | makePublic( ai, "resolution" ); 164 | setResolutionCookie( "300-2" );// set as retina display so multiplier is applied 165 | expect( ai.resolution() ).toBe( 480 );// retina resolution is 450. Expect next highest defined 166 | } ); 167 | 168 | it( "returns the source image if the detected resolution exceeds the largest defined", function() { 169 | ai.$property( propertyName: "sendImage", mock: sendImage ); 170 | setResolutionCookie( "500-1" ); 171 | expect( ai.process( sourceImageUrl ) ).toBe( sourceImagePath ); 172 | } ); 173 | 174 | it( "detects and caches cached file existence", function() { 175 | makePublic( ai, "cachedFileExists" ); 176 | var cacheFilePath = createCachedFile( 300, ai ); 177 | expect( ai.cachedFileExists( cacheFilePath ) ).toBeTrue(); 178 | var cache = ai.getFileOperationsCache(); 179 | expect( cache ).toHaveKey( cacheFilePath ); 180 | deleteFolder( GetDirectoryFromPath( cacheFilePath ) ); 181 | } ); 182 | 183 | it( "sends cached image if it exists", function() { 184 | ai.$property( propertyName: "sendImage", mock: sendImage ); 185 | ai[ "setFileOperationsCacheValue" ] = setFileOperationsCacheValue; // add this temporary method 186 | var cacheFilePath = createCachedFile( 320, ai ); 187 | ai.setFileOperationsCacheValue( cacheFilePath, true ); 188 | setResolutionCookie( "300-1" ); 189 | expect( ai.process( sourceImageUrl ) ).toBe( cacheFilePath ); 190 | deleteFolder( GetDirectoryFromPath( cacheFilePath ) ); 191 | } ); 192 | 193 | it( "sends source image if resolution exceeds source width", function() { 194 | ai.$property( propertyName: "sendImage", mock: sendImage ); 195 | // test image is 600px wide 196 | setResolutionCookie( "700-1" ); 197 | expect( ai.process( sourceImageUrl ) ).toBe( sourceImagePath ); 198 | } ); 199 | 200 | it( "generateImage resizes to specified width", function() { 201 | var sourceImage = ImageRead( sourceImagePath ); 202 | makePublic( ai, "generateImage" ); 203 | var resizedImage = ai.generateImage( sourceImage, 320 ); 204 | expect( resizedImage.width ).toBe( 320 ); 205 | } ); 206 | 207 | it( "creates cache folder and caches path", function() { 208 | makePublic( ai, "ensureCacheFolderExists" ); 209 | var cacheFolderPath = imageFolderPath & 320 & "/"; 210 | deleteFolder( cacheFolderPath );//ensure it doesn't exist. 211 | ai.ensureCacheFolderExists( cacheFolderPath ); 212 | expect( DirectoryExists( cacheFolderPath ) ).toBeTrue(); 213 | var cache = ai.getFileOperationsCache(); 214 | expect( cache ).toHaveKey( cacheFolderPath ); 215 | deleteFolder( cacheFolderPath ); 216 | } ); 217 | 218 | it( "creates cache folder and caches path using optional cache folder name", function() { 219 | variables.ai = New root.adaptiveImages( resolutions: [ "480","320" ], cacheFolderName: "ai-cache" ); 220 | makePublic( ai, "ensureCacheFolderExists" ); 221 | var cacheFolderPath = imageFolderPath & "ai-cache/" & 320 & "/"; 222 | deleteFolder( cacheFolderPath );//ensure it doesn't exist. 223 | ai.ensureCacheFolderExists( cacheFolderPath ); 224 | expect( DirectoryExists( cacheFolderPath ) ).toBeTrue(); 225 | var cache = ai.getFileOperationsCache(); 226 | expect( cache ).toHaveKey( cacheFolderPath ); 227 | deleteFolder( imageFolderPath & "ai-cache/" ); 228 | } ); 229 | 230 | it( "caches a copy of the source if the resized image is larger in bytes", function() { 231 | makePublic( ai, "checkCachedImageIsNotLargerThanSource" ); 232 | var largerTestImagePath = imageFolderPath & "largerFileSizeThanSource.jpg"; 233 | var cacheFolderPath = imageFolderPath & 320 & "/"; 234 | deleteFolder( cacheFolderPath );//ensure it doesn't exist. 235 | DirectoryCreate( cacheFolderPath ); 236 | cachedFilePath = cacheFolderPath & "test.jpg"; 237 | FileCopy( largerTestImagePath, cachedFilePath ); 238 | ai.checkCachedImageIsNotLargerThanSource( cachedFilePath, sourceImagePath ); 239 | var expected = GetFileInfo( sourceImagePath ).size; 240 | var actual = GetFileInfo( cachedFilePath ).size; 241 | expect( actual ).toBe( expected ); 242 | deleteFolder( cacheFolderPath ); 243 | } ); 244 | 245 | it( "caches and sends an image sized to the detected resolution if not already in cache", function() { 246 | ai.$property( propertyName: "sendImage", mock: sendImage ); 247 | var cacheFolderPath = imageFolderPath & 320 & "/"; 248 | var cacheFilePath = cacheFolderPath & "test.jpg"; 249 | deleteFolder( cacheFolderPath );//ensure it doesn't exist. 250 | setResolutionCookie( "300-1" ); 251 | expect( ai.process( sourceImageUrl ) ).toBe( cacheFilePath ); 252 | deleteFolder( cacheFolderPath ); 253 | } ); 254 | 255 | it( "caches and sends an image sized to the detected resolution if not already in cache, using optional cache folder name", function() { 256 | variables.ai = New root.adaptiveImages( resolutions: [ "480","320" ], cacheFolderName: "ai-cache" ); 257 | prepareMock( ai ); 258 | ai.$property( propertyName: "sendImage", mock: sendImage ); 259 | var cacheFolderPath = imageFolderPath & "ai-cache/" & 320 & "/"; 260 | var cacheFilePath = cacheFolderPath & "test.jpg"; 261 | deleteFolder( cacheFolderPath );//ensure it doesn't exist. 262 | setResolutionCookie( "300-1" ); 263 | expect( ai.process( sourceImageUrl ) ).toBe( cacheFilePath ); 264 | deleteFolder( imageFolderPath & "ai-cache/" ); 265 | } ); 266 | 267 | it( "clears the file operations cache if it contains a cached image which no longer exists on the file system", function(){ 268 | variables.ai = New root.adaptiveImages( resolutions: [ "480","320" ], writeLogs: true ); 269 | prepareMock( ai ); 270 | ai.$property( propertyName: "sendImage", mock: sendImage ); 271 | var cacheFolderPath = imageFolderPath & 320 & "/"; 272 | var cacheFilePath = cacheFolderPath & "test.jpg"; 273 | setResolutionCookie( "300-1" ); 274 | ai.process( sourceImageUrl ); 275 | var cache = ai.getFileOperationsCache(); 276 | expect( cache ).toHaveKey( cacheFolderPath ); 277 | deleteFolder( cacheFolderPath ); 278 | ai.process( sourceImageUrl ); 279 | expect( cache ).toBeEmpty(); 280 | expect( ai.process( sourceImageUrl ) ).toBe( cacheFilePath ); 281 | deleteFolder( cacheFolderPath ); 282 | } ); 283 | 284 | describe( "cleanupCacheFolders", function(){ 285 | 286 | it( "deletes a cached image where the source no longer exists", function() { 287 | var cacheFilePath = createCachedFile( 320, ai, "nonexistantsource.jpg" ); 288 | expect( FileExists( cacheFilePath ) ).toBeTrue(); 289 | ai.cleanupCacheFolders( imageFolderUrl ); 290 | expect( FileExists( cacheFilePath ) ).toBeFalse(); 291 | // deletes empty resolution cache folders 292 | expect( DirectoryExists( GetDirectoryFromPath( cacheFilePath ) ) ).toBeFalse(); 293 | } ); 294 | 295 | it( "deletes a cached image where the source no longer exists using optional cache folder name", function() { 296 | variables.ai = New root.adaptiveImages( resolutions: [ "480","320" ], cacheFolderName: "ai-cache" ); 297 | var cacheFilePath = createCachedFile( 320, ai, "nonexistantsource.jpg" ); 298 | expect( FileExists( cacheFilePath ) ).toBeTrue(); 299 | ai.cleanupCacheFolders( imageFolderUrl ); 300 | expect( FileExists( cacheFilePath ) ).toBeFalse(); 301 | // deletes empty resolution cache folders 302 | expect( DirectoryExists( GetDirectoryFromPath( cacheFilePath ) ) ).toBeFalse(); 303 | deleteFolder( imageFolderPath & "ai-cache/" ); 304 | } ); 305 | 306 | }); 307 | 308 | describe( "deleteCachedCopies", function() { 309 | 310 | it( "deletes cached resolution images for a given image", function() { 311 | var cacheFilePath1 = createCachedFile( 320, ai ); 312 | var cacheFilePath2 = createCachedFile( 480, ai ); 313 | expect( FileExists( cacheFilePath1 ) ).toBeTrue(); 314 | expect( FileExists( cacheFilePath2 ) ).toBeTrue(); 315 | ai.deleteCachedCopies( sourceImagePath ); 316 | expect( FileExists( cacheFilePath1 ) ).toBeFalse(); 317 | expect( FileExists( cacheFilePath2 ) ).toBeFalse(); 318 | ai.cleanupCacheFolders( imageFolderUrl ); 319 | } ); 320 | 321 | it( "deletes cached resolution images for a given image using optional cache folder name", function() { 322 | variables.ai = New root.adaptiveImages( resolutions: [ "480","320" ], cacheFolderName: "ai-cache" ); 323 | var cacheFilePath1 = createCachedFile( 320, ai ); 324 | var cacheFilePath2 = createCachedFile( 480, ai ); 325 | expect( FileExists( cacheFilePath1 ) ).toBeTrue(); 326 | expect( FileExists( cacheFilePath2 ) ).toBeTrue(); 327 | ai.deleteCachedCopies( sourceImagePath ); 328 | expect( FileExists( cacheFilePath1 ) ).toBeFalse(); 329 | expect( FileExists( cacheFilePath2 ) ).toBeFalse(); 330 | ai.cleanupCacheFolders( imageFolderUrl ); 331 | deleteFolder( imageFolderPath & "ai-cache/" ); 332 | } ); 333 | 334 | } ); 335 | 336 | }); 337 | 338 | } 339 | 340 | /* Mock */ 341 | 342 | void function setFileOperationsCacheValue( required key, required value ){ 343 | fileOperationsCache[ key ] = value; 344 | } 345 | 346 | string function sendImage( required string filepath ){ 347 | // simulate reading the file 348 | //ImageRead( filepath ); 349 | //can't test actual sending, so just return the selected image path 350 | return filepath; 351 | } 352 | 353 | /* Helpers */ 354 | string function forwardSlashes( required string path ){ 355 | return path.Replace( "\", "/", "ALL" ); 356 | } 357 | 358 | string function createCachedFile( required numeric resolution, required ai, filename = imageFilename ){ 359 | makePublic( ai, "resolutionFolderName" ); 360 | var filePath = imageFolderPath & ai.resolutionFolderName( resolution ) & "/" & filename; 361 | createFile( filePath ); 362 | return filePath; 363 | } 364 | 365 | void function createFile( path ){ 366 | var folderPath = GetDirectoryFromPath( path ); 367 | if( !DirectoryExists( folderPath ) ){ 368 | lock name=folderPath timeout=5{ 369 | DirectoryCreate( folderPath ); 370 | } 371 | } 372 | if( !FileExists( path ) ) 373 | FileWrite( path, "test" ); 374 | } 375 | 376 | void function deleteFolder( path ){ 377 | if( DirectoryExists( path ) ){ 378 | lock name=path timeout=5{ 379 | DirectoryDelete( path, true ); 380 | } 381 | } 382 | } 383 | 384 | void function setResolutionCookie( required string value ){ 385 | cfcookie( name="resolution", value="#value#" ); 386 | } 387 | 388 | void function deleteResolutionCookie(){ 389 | cfcookie( name="resolution", value="deleted", expires="now" ); 390 | } 391 | 392 | } --------------------------------------------------------------------------------