From 971ed82bdff1c1ac127a2fbc372647124857dbab Mon Sep 17 00:00:00 2001 From: jkorff Date: Sun, 24 Apr 2016 14:37:25 +1000 Subject: [PATCH] Added lazy-loading of iframed videos Extended Easy Jail to also lazy-load iframed videos (e.g. from YouTube). --- jail.js | 475 +++++++++++++++++++++++++++++++++++++++++++++++ jail.min.js | 1 + pi.easy_jail.php | 301 ++++++++++++++++++++++++++++++ 3 files changed, 777 insertions(+) create mode 100644 jail.js create mode 100644 jail.min.js create mode 100644 pi.easy_jail.php diff --git a/jail.js b/jail.js new file mode 100644 index 0000000..3dbe1ba --- /dev/null +++ b/jail.js @@ -0,0 +1,475 @@ +/* +* JAIL: jQuery Asynchronous Image Loader +* +* Copyright (c) 2011-12 Sebastiano Armeli-Battana (http://www.sebastianoarmelibattana.com) +* +* By Sebastiano Armeli-Battana (@sebarmeli) +* Licensed under the MIT license. +* https://github.com/sebarmeli/JAIL/blob/master/MIT-LICENSE.txt +* +* Tested with jQuery 1.3.2+ on FF 2+, Opera 10+, Safari 4+, Chrome 8+ on Win/Mac/Linux +* and IE 6/7/8 on Win. +* +* Contributor : Derek Lindahl - @dlindahl +* +* This version changed by Jens Korff, Creative Spirits, to include iframe jailing +* +* @link http://github.com/sebarmeli/JAIL +* @author Sebastiano Armeli-Battana +* @date 14/10/2012 +* @version 1.0.0 +* +*/ +;(function ( name, definition ){ + + var theModule = definition(jQuery), + hasDefine = typeof define === 'function' && define.amd; + + if ( hasDefine ){ // AMD module + + define( name , ['jquery'], theModule ); + + } else { // assign 'jail' to global objects + + ( this.jQuery || this.$ || this )[name] = theModule; + + } +}( 'jail', function ($) { + + var $window = $( window ), + + // Defaults parameters + defaults = { + id : 'jail', + timeout : 1, + effect : false, + speed : 400, + triggerElement: null, + offset : 0, + event : 'load', + callback : null, + callbackAfterEachImage : null, + placeholder : false, + loadHiddenImages : false + }, + + // current stack of images + currentStack = [], + + // true if 'callback' fn is called + isCallbackDone = false; + + /* + * Public function defining 'jail' + * + * @module jail + * @param elems : images to load - jQuery elements + * @param opts : configurations object + */ + $.jail = function( elems, opts ) { + + var elements = elems || {}, + options = $.extend( {}, defaults, opts ); + + // Initialize plugin + $.jail.prototype.init( elements, options ); + + // When the event is not specified the images will be loaded with a delay + if(/^(load|scroll)/.test( options.event )) { + // 'load' event + $.jail.prototype.later.call( elements, options ); + } else { + $.jail.prototype.onEvent.call( elements, options ); + } + }; + + /* + * Method in charge of initializing the plugin, storing + * the 'element triggering the image to load' in a data attribute + * for each image and displaying the placeholder image (if existing) + * + * @method init + * @param {Array} elems Images to load - jQuery elements + * @param {Object} opts Configurations object + */ + $.jail.prototype.init = function( elements, options ) { + + // Store the selector triggering jail into 'triggerElem' data for the images selected + elements.data("triggerElem", ( options.triggerElement ) ? $( options.triggerElement ) : $window ); + + // Use a placeholder in case it is specified + if ( !!options.placeholder ) { + elements.each(function(){ + $(this).attr( "src", options.placeholder ); + }); + } + }; + + + /* + * Function called when 'event' is different from "load" or "scroll". Two scenarios: + * a) Element triggering the images to be loaded (events available on the element: "click", "mouseover", "scroll") + * b) Event on the image itself triggering the image to be loaded + * + * @param options : configurations object + */ + $.jail.prototype.onEvent = function( options ) { + var images = this; + + if (!!options.triggerElement) { + + // Event on the 'triggerElement' obj + _bindEvent( options, images ); + + } else { + + // Event on the image itself + images.on( options.event + '.' + options.id, {options: options, images: images}, function(e) { + var $img = $(this), + options = e.data.options, + images = e.data.images; + + currentStack = $.extend( {}, images ); + + // Load the image + _loadImage( options, $img ); + + // Image has been loaded so there is no need to listen anymore + $(e.currentTarget).unbind( e.type + '.' + options.id ); + }); + } + }; + + /* + * Method called when "event" is equals to "load" (default) or "scroll". The visible images will be + * loaded after a specified timeout (or after 1 ms). The scroll method will be bound to the window + * to load the images not visible onload. + * + * @param options : configurations object + */ + $.jail.prototype.later = function( options ) { + var images = this; + + // After [timeout] has elapsed, load the visible images + setTimeout(function() { + + currentStack = $.extend( {}, images ); + + //Load the visible ones + images.each(function(){ + _loadImageIfVisible( options, this, images ); + }); + + // When images become available (scrolling or resizing), they will be loaded + options.event = "scroll"; + _bindEvent( options, images ); + + }, options.timeout); + }; + + /* + * Bind _bufferedEventListener() to the event on window/triggerElement. The handler is bound to + * resizing the window as well + * + * @param options : configurations object + * @param images : images in the current stack + */ + function _bindEvent ( options, images ) { + var triggerElem = false; + + if (!!images) { + triggerElem = images.data("triggerElem"); + } + + // Check if there are images to load + if (!!triggerElem && typeof triggerElem.on === "function") { + triggerElem.on( options.event + '.' + options.id, {options:options, images : images}, _bufferedEventListener ); + $window.on( 'resize.'+options.id, {options:options, images : images}, _bufferedEventListener ); + } + } + + /* + * Remove any elements that have been loaded from the jQuery stack. + * This should speed up subsequent calls by not having to iterate over the loaded elements. + * + * @param stack : current images stack + */ + function _purgeStack ( stack ) { + // number of images not loaded + var i = 0; + + if (stack.length === 0) { return; } + + // Check on existence of 'data-src' attribute to verify if the image has been loaded + while(true) { + if(i === stack.length) { + break; + } else { + if ($(stack[i]).attr('data-src')) { + i++; + } else { + stack.splice( i, 1 ); + } + } + } + } + + /* + * Event handler for the images to be loaded. Function called when + * there is a triggerElement or when there are images to be loaded after scrolling + * or resizing window/container + * + * @param e : event + */ + function _bufferedEventListener (e) { + var images = e.data.images, + options = e.data.options; + + images.data('poller', setTimeout(function() { + + currentStack = $.extend( {}, images ); + _purgeStack(currentStack); + + // Load only the images left + $(currentStack).each(function (){ + if (this === window) { + return; + } + _loadImageIfVisible(options, this, currentStack); + }); + + //Unbind when there are no images + if ( _isAllImagesLoaded (currentStack) ) { + $(e.currentTarget).unbind( e.type + '.' + options.id ); + return; + } + // When images are not in the viewport, let's load them when they become available + else if (options.event !== "scroll"){ + + // When images become available (scrolling or resizing), they will be loaded + var container = (/scroll/i.test(options.event)) ? images.data("triggerElem") : $window; + + options.event = "scroll"; + images.data("triggerElem", container); + _bindEvent( options, $(currentStack) ); + } + }, options.timeout)); + } + + /* + * Check if all the images are loaded + * + * @param images : images under analysis + * @return boolean + */ + function _isAllImagesLoaded ( images ) { + var bool = true; + + $(images).each(function(){ + if ( !!$(this).attr("data-src") ) { + bool = false; + } + }); + return bool; + } + + /* + * Load the image if visible in the viewport + * + * @param options : configurations object + * @param image : image under analysis + * @param images : list of images to load + */ + function _loadImageIfVisible ( options, image, images ) { + var $img = $(image), + container = (/scroll/i.test(options.event)) ? images.data("triggerElem") : $window, + isVisible = true; + + // If don't you want to load hidden images (default beahviour) + if ( !options.loadHiddenImages ) { + isVisible = _isVisibleInContainer( $img, container, options ) && $img.is(":visible"); + } + + // Load the image if it is not hidden and visible in the screen + if( isVisible && _isInTheScreen( container, $img, options.offset ) ) { + _loadImage( options, $img ); + } + } + + /* + * Function that returns true if the image is visible inside the "window" (or specified container element) + * + * @param $ct : container - jQuery obj + * @param $img : image selected - jQuery obj + * @param optionOffset : offset + */ + function _isInTheScreen ( $ct, $img, optionOffset ) { + var is_ct_window = $ct[0] === window, + ct_offset = (is_ct_window ? { top:0, left:0 } : $ct.offset()), + ct_top = ct_offset.top + ( is_ct_window ? $ct.scrollTop() : 0), + ct_left = ct_offset.left + ( is_ct_window ? $ct.scrollLeft() : 0), + ct_right = ct_left + $ct.width(), + ct_bottom = ct_top + $ct.height(), + img_offset = $img.offset(), + img_width = $img.width(), + img_height = $img.height(); + + return (ct_top - optionOffset) <= (img_offset.top + img_height) && + (ct_bottom + optionOffset) >= img_offset.top && + (ct_left - optionOffset)<= (img_offset.left + img_width) && + (ct_right + optionOffset) >= img_offset.left; + } + + /* + * Main function --> Load the images copying the "data-href" attribute into the "src" attribute + * + * @param options : configurations object + * @param $img : image selected - jQuery obj + */ + function _loadImage ( options, $img ) { + + // Use cache Image object to show images only when ready + + // Image caching + if ($img[0].nodeName == "IMG") { + + var cache = new Image(); + cache.onload = function() { + // Prevent duplicate concurrent calls for the same image object + if ( undefined === $img.attr('data-src') ) return; + + cache_onload(cache, $img, options); + }; + + /* + * Basic on error handler. For now we limit + * the plugin to exectute any callback passed + * in the options object. Let the developer + * decide what to do with it. + */ + cache.onerror = function(){ + + if(!('error' in options)) return; + + var args = Array.prototype.slice.call(arguments, 0); + args = [$img, options].concat(args); + options.error.apply($.jail, args); + }; + + cache.src = $img.attr("data-src"); + } + + // Video (iframe) caching + else if ($img[0].nodeName == "IFRAME") { + // Check if iframe has already been loaded + if ( $('iframe[src="' + $img.attr("data-src") + '"]').length > 0 ) { + return; + } + + var frame = document.createElement('iframe'); + document.body.appendChild(frame); + + // Wait for iframe to be ready for DOM manipulation + // http://stackoverflow.com/questions/1463581/wait-for-iframe-to-load-in-javascript + $(frame).load( function(){ + cache_onload(frame, $img, options); + }) + + frame.src = $img.attr("data-src"); + } + } + + // Common cache function for images and iframes + function cache_onload(cache, $img, options) { + $img.hide().attr("src", cache.src); + + $img.removeAttr('data-src'); + // Images loaded with some effect if existing + if( options.effect) { + + if ( options.speed ) { + $img[options.effect](options.speed); + } else { + $img[options.effect](); + } + $img.css("opacity", 1); + $img.show(); + } else { + $img.show(); + } + + $(cache).remove(); // Required for iframes + + _purgeStack(currentStack); + + // Callback after each image is loaded + if ( !!options.callbackAfterEachImage ) { + options.callbackAfterEachImage.call( this, $img, options ); + } + + if ( _isAllImagesLoaded (currentStack) && !!options.callback && !isCallbackDone ) { + options.callback.call($.jail, options); + isCallbackDone = true; + } + } + + /* + * Return if the image is visible inside a "container" / window. There are checks around + * "visibility" CSS property and around "overflow" property of the "container" + * + * @param $img : image selected - jQuery obj + * @param container : container object + * @param options : configurations object + */ + function _isVisibleInContainer ( $img, container, options ){ + + var parent = $img.parent(), + isVisible = true; + + while ( parent.length && parent.get(0).nodeName.toUpperCase() !== "BODY" ) { + // Consider the 'overflow' property + if ( parent.css("overflow") === "hidden" ) { + if (!_isInTheScreen(parent, $img, options.offset)) { + isVisible = false; + break; + } + } else if ( parent.css("overflow") === "scroll" ) { + if (!_isInTheScreen(parent, $img, options.offset)) { + isVisible = false; + $(currentStack).data("triggerElem", parent); + + options.event = "scroll"; + _bindEvent(options, $(currentStack)); + break; + } + } + + if ( parent.css("visibility") === "hidden" || $img.css("visibility") === "hidden" ) { + isVisible = false; + break; + } + + // If container is not the window, and the parent is the container, exit from the loop + if ( container !== $window && parent === container ) { + break; + } + + parent = parent.parent(); + } + + return isVisible; + } + + // Small wrapper + $.fn.jail = function( options ) { + + new $.jail( this, options ); + + // Empty current stack + currentStack = []; + + return this; + }; + + return $.jail; +})); diff --git a/jail.min.js b/jail.min.js new file mode 100644 index 0000000..f6adb92 --- /dev/null +++ b/jail.min.js @@ -0,0 +1 @@ +!function(e,t){var i=t(jQuery),a="function"==typeof define&&define.amd;a?define(e,["jquery"],i):(this.jQuery||this.$||this)[e]=i}("jail",function(e){function t(e,t){var i=!1;t&&(i=t.data("triggerElem")),i&&"function"==typeof i.on&&(i.on(e.event+"."+e.id,{options:e,images:t},a),f.on("resize."+e.id,{options:e,images:t},a))}function i(t){var i=0;if(0!==t.length)for(;;){if(i===t.length)break;e(t[i]).attr("data-src")?i++:t.splice(i,1)}}function a(a){var o=a.data.images,l=a.data.options;o.data("poller",setTimeout(function(){if(u=e.extend({},o),i(u),e(u).each(function(){this!==window&&n(l,this,u)}),r(u))return void e(a.currentTarget).unbind(a.type+"."+l.id);if("scroll"!==l.event){var c=/scroll/i.test(l.event)?o.data("triggerElem"):f;l.event="scroll",o.data("triggerElem",c),t(l,e(u))}},l.timeout))}function r(t){var i=!0;return e(t).each(function(){e(this).attr("data-src")&&(i=!1)}),i}function n(t,i,a){var r=e(i),n=/scroll/i.test(t.event)?a.data("triggerElem"):f,c=!0;t.loadHiddenImages||(c=s(r,n,t)&&r.is(":visible")),c&&o(n,r,t.offset)&&l(t,r)}function o(e,t,i){var a=e[0]===window,r=a?{top:0,left:0}:e.offset(),n=r.top+(a?e.scrollTop():0),o=r.left+(a?e.scrollLeft():0),l=o+e.width(),c=n+e.height(),s=t.offset(),f=t.width(),d=t.height();return n-i<=s.top+d&&c+i>=s.top&&o-i<=s.left+f&&l+i>=s.left}function l(t,i){if("IMG"==i[0].nodeName){var a=new Image;a.onload=function(){void 0!==i.attr("data-src")&&c(a,i,t)},a.onerror=function(){if("error"in t){var a=Array.prototype.slice.call(arguments,0);a=[i,t].concat(a),t.error.apply(e.jail,a)}},a.src=i.attr("data-src")}else if("IFRAME"==i[0].nodeName){if(e('iframe[src="'+i.attr("data-src")+'"]').length>0)return;var r=document.createElement("iframe");document.body.appendChild(r),e(r).load(function(){c(r,i,t)}),r.src=i.attr("data-src")}}function c(t,a,n){a.hide().attr("src",t.src),a.removeAttr("data-src"),n.effect?(n.speed?a[n.effect](n.speed):a[n.effect](),a.css("opacity",1),a.show()):a.show(),e(t).remove(),i(u),n.callbackAfterEachImage&&n.callbackAfterEachImage.call(this,a,n),r(u)&&n.callback&&!p&&(n.callback.call(e.jail,n),p=!0)}function s(i,a,r){for(var n=i.parent(),l=!0;n.length&&"BODY"!==n.get(0).nodeName.toUpperCase();){if("hidden"===n.css("overflow")){if(!o(n,i,r.offset)){l=!1;break}}else if("scroll"===n.css("overflow")&&!o(n,i,r.offset)){l=!1,e(u).data("triggerElem",n),r.event="scroll",t(r,e(u));break}if("hidden"===n.css("visibility")||"hidden"===i.css("visibility")){l=!1;break}if(a!==f&&n===a)break;n=n.parent()}return l}var f=e(window),d={id:"jail",timeout:1,effect:!1,speed:400,triggerElement:null,offset:0,event:"load",callback:null,callbackAfterEachImage:null,placeholder:!1,loadHiddenImages:!1},u=[],p=!1;return e.jail=function(t,i){var a=t||{},r=e.extend({},d,i);e.jail.prototype.init(a,r),/^(load|scroll)/.test(r.event)?e.jail.prototype.later.call(a,r):e.jail.prototype.onEvent.call(a,r)},e.jail.prototype.init=function(t,i){t.data("triggerElem",i.triggerElement?e(i.triggerElement):f),i.placeholder&&t.each(function(){e(this).attr("src",i.placeholder)})},e.jail.prototype.onEvent=function(i){var a=this;i.triggerElement?t(i,a):a.on(i.event+"."+i.id,{options:i,images:a},function(t){var i=e(this),a=t.data.options,r=t.data.images;u=e.extend({},r),l(a,i),e(t.currentTarget).unbind(t.type+"."+a.id)})},e.jail.prototype.later=function(i){var a=this;setTimeout(function(){u=e.extend({},a),a.each(function(){n(i,this,a)}),i.event="scroll",t(i,a)},i.timeout)},e.fn.jail=function(t){return new e.jail(this,t),u=[],this},e.jail}); \ No newline at end of file diff --git a/pi.easy_jail.php b/pi.easy_jail.php new file mode 100644 index 0000000..e4593ce --- /dev/null +++ b/pi.easy_jail.php @@ -0,0 +1,301 @@ + 'Easy JAIL', + 'pi_version' => '1.0', + 'pi_author' => 'Aaron Gustafson', + 'pi_author_url' => 'http://easy-designs.net/', + 'pi_description' => 'Automates the implementation of Sebastiano Armeli-Battana’s jQuery Asynchronous Image Loader Plugin', + 'pi_usage' => Easy_jail::usage() +); + +class Easy_jail { + + var $return_data; +// var $template = ''; + # Add wrapper around tag to allow improved CSS to prevent distorted blank_img + var $template_img = ''; + var $template_video = ''; + var $xhtml = TRUE; + var $class_name = 'jail'; + var $blank_img = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; + var $blank_video = '/spinner.html'; // iframe needs HTML document so we can centre the spinner image + var $alt = ''; + var $config = ''; + + /** + * Easy_jail constructor + * sets any overrides and triggers the processing + * + * @param str $str - the content to be parsed + */ + function __construct() + { + $this->return_data = ee()->TMPL->tagdata; + } # end Easy_jail constructor + + /** + * Easy_jail::prep() + * processes the supplied content based on the configuration + * + * @param str $str - the content to be parsed + */ + function prep( $str='', $xhtml='', $class_name='', $blank_img='' ) + { + # get any tag overrides + if ( empty( $xhtml ) ) + { + $xhtml = ee()->TMPL->fetch_param( 'xhtml', $this->xhtml ); + } + if ( empty( $class_name ) ) + { + $class_name = ee()->TMPL->fetch_param( 'class_name', $this->class_name ); + } + if ( empty( $blank_img ) ) + { + $blank_img = ee()->TMPL->fetch_param( 'blank_img', ( empty( $blank_img ) ? $this->blank_img : $blank_img ) ); + } + + # Fetch string + if ( empty( $str ) ) + { + $str = ee()->TMPL->tagdata; + } + + # trim + $str = trim( $str ); + + # Replace images + $lookup = '/(]*)\/?>)/'; + $str = $this->find_matches($lookup, $str, 'image'); + + # Replace iframed videos + $lookup = '/(]*)\/?><\/iframe>)/'; // Needs to be improved so it doesn't catch Google maps + $str = $this->find_matches($lookup, $str, 'video'); + + $this->return_data = $str; + + return $this->return_data; + + } # end Easy_jail::prep() + + + private function find_matches($needle, $haystack, $element_type) + { + if ( preg_match_all( $needle, $haystack, $found, PREG_SET_ORDER ) ) + { + + # loop the matches + foreach ( $found as $instance ) + { + $old_element = $instance[1]; + $src = ''; + + # get the attributes + $attributes = array(); + + # remove the / + if ( substr( $instance[2], -1, 1 ) == '/' ) + { + $instance[2] = substr( $instance[2], 0, -1 ); + } + + # Get delimiter of "alt" attribute (as this is the critical one [can contain " or ' inside text]) + $altAttributeExists = preg_match('/alt=([\'"])/', $instance[2], $matches); + if ( $altAttributeExists != false ) { + $delimiter = $matches[1]; + } else { + $delimiter = '"'; + } + + # Get all attributes + # Reference: http://stackoverflow.com/questions/138313/how-to-extract-img-src-title-and-alt-from-html-using-php#answer-2937682 + $doc = new DOMDocument(); + @$doc->loadHTML($old_element); + + switch ( $element_type ) { + case 'image': + $tags = $doc->getElementsByTagName('img'); + break; + case 'video': + $tags = $doc->getElementsByTagName('iframe'); + break; + } + + foreach ( $tags as $tag ) + { + foreach ( $tag->attributes as $attribute ) + { + $name = $attribute->name; + $value = $attribute->value; + + if ( $name == 'src' ) + { + $src = $value; + } + elseif ( $name == 'class' ) + { + $this->class_name .= " {$value}"; + } + else + { + $attributes[$name] = $name . '=' . $delimiter . $value . $delimiter; + } + } + } + + switch ( $element_type ) { + + case 'image': + # enforce an alt attribute + if ( ! isset( $attributes['alt'] ) ) + { + $attributes['alt'] = $this->alt; + } + + # build the new image + $swap = array( + 'attributes' => implode( ' ', $attributes ), + 'class_name' => $this->class_name, + 'blank_img' => $this->blank_img, + 'real_img' => $src + ); + $template = $this->template_img; + break; + + case 'video': + # build the new video + $swap = array( + 'attributes' => implode( ' ', $attributes ), + 'class_name' => $this->class_name, + 'blank_img' => $this->blank_video, + 'real_vid' => $src + ); + $template = $this->template_video; + break; + } + + $new_element = ee()->functions->var_swap( $template, $swap ); + + # XHTML? + if ( ! $this->xhtml ) + { + $new_element = str_replace( '/>', '>', $new_element ); + } + + $haystack = str_replace( $old_element, $new_element, $haystack ); + + } # end foreach instance + + } # end if match + + return $haystack; + + } + + /** + * Easy_jail::js() + * Builds the JS trigger + */ + function js( $class_name='', $config='' ) + { + $js = ''; + + # get tag params + if ( empty( $class_name ) ) + { + $class_name = ee()->TMPL->fetch_param( 'class_name', $this->class_name ); + } + if ( empty( $config ) ) + { + $config = ee()->TMPL->fetch_param( 'config', $this->config ); + } + + # get JAIL + $js .= file_get_contents( PATH_THIRD . '/easy_jail/vendors/jail/dist/jail.min.js' ) . "\n\n"; +// $js .= file_get_contents( PATH_THIRD . '/easy_jail/vendors/jail/src/jail.js' ) . "\n\n"; // Use for debugging + + # build the trigger + $template = '(function(window,$){$(".{class_name}").jail({jail_config});$(window).on("load",function(){$(this).resize();});}(this,jQuery));'; + $swap = array( + 'class_name' => $class_name, + 'jail_config' => $config + ); + $js .= ee()->functions->var_swap( $template, $swap ); + + $this->return_data = ''; + + return $this->return_data; + + } # end Easy_jail::js() + + /** + * Easy_jail::usage() + * Describes how the plugin is used + */ + public static function usage() + { + ob_start(); ?> +First off, this plugin requires jQuery. Using it requires 2 steps: + +1) Wrap the markup you want to JAIL in {exp:easy_jail:prep} + +{exp:easy_jail:prep} + {body} +{/exp:easy_jail:prep} + +This will cause the plugin to convert + + + +into + + + + +Providing it with additional params allows you to customize certain bits: + +* xhtml="n" - HTML output +* blank_img="my_blank.gif" - Your custom blank image +* class_name="custom_class" - Your custom class choice + +2) Include {exp:easy_jail:js} at the end of your body element, after you included jQuery. + + + +{exp:easy_jail:js} + +By default, this will include the JAIL script and a baseline configuration. To configure the output of the script, you can use the following parameters: + +* class_name="custom_class" - Your custom class choice +* config="{offset:300}" - Your custom configuration (see http://sebarmeli.github.io/JAIL/ for a run-down of options) +