Constructing targetted CSS

I am working on a page with a responsive design. The page is really two pages, assembled with PHP. This allows the content and functionality to be reduced for smaller mobile devices, and enhanced for tablets and desktops.

In order to provide consistent presentation across all devices, I broke the CSS into several files. All the CSS files are then compressed with the YUI compressor and concatenated into device specific files.

The device specific files are:

  • mobile.css - For devices that are not more than 360px wide
  • desktop_and_tablet.css - For devices that are more than 360px wide. Media queries indicate resolution specific elements.

Code

for F in "$CSSDIR/"*.css; do
        # Compress
        echo -e "\t$F"
        `$YUICOMPRESSOR "$F" > "$TMPDIR/$F"`
done;
 
echo Concatenating CSS ...
# Construct targeted version
CSSTMPDIR="$TMPDIR/$CSSDIR"
cat "$CSSTMPDIR/reset.css" "$CSSTMPDIR/core.css" "$CSSTMPDIR/mobile.css" > "$MINDIR/mobile.css"
cat "$CSSTMPDIR/reset.css" "$CSSTMPDIR/core.css" "$CSSTMPDIR/color.css" "$CSSTMPDIR/desktop.css" "$CSSTMPDIR/tablet.css" "$CSSTMPDIR/js.css" "$CSSTMPDIR/common.css" > "$MINDIR/desktop_and_tablet.css"

The component CSS files are:

  • color.css - All color definitions
  • common.css - CSS that is common to both tablet and desktop devices
  • core.css - CSS that is common to all devices. Examples include setting the font, some common padding and margins.
  • desktop.css - Styles targeted for desktop browsers
  • js.css - Styles for pages which rely on JavaScript. These styles are applied if the body has the ‘js’ class applied, which indicates the client supports JavaScript.
  • mobile.css - The CSS for the smallest supported browsers
  • reset.css - The reset.css, thanks to: http://meyerweb.com/eric/tools/css/reset/
  • tablet.css - Styles for devices between 361px and 980px wide, inclusive

The advantages of this approach is that the style settings are not duplicated, they need only be maintained in one place. The content delivered to the mobile device includes only the styles and content which will be used, ensuring the leanest pages possible. The tablets and desktops, receive the CSS for tablets and desktops, but display the same content. This allows those pages to adjust dynamically to width changes.

All devices include a small JavaScript file which handles the page reload if the width of the device is modified outside the target bounds of the loaded page.

This is the JavaScript that detects and responds to the width change for mobile devices. It does require JavaScript, the page does not use media queries, since that would require more CSS and content to be loaded to support transitions.

Code

function widthCheck() {
        if ((document.body.scrollWidth && document.body.scrollWidth > 360) ||
                (innerWidth && innerWidth > 360) ||
                (document.body.clientWidth && document.body.clientWidth > 360)) {
                        location.href = "index.php?notmobile";
        }
}
 
window.onload = widthCheck;
window.onresize = widthCheck;

The ?notmobile overrides the mobile device detection code.

On the tablets and desktops, jQuery is used and the code to detect and react to width changes is:

Code

$(window).resize(function() {
                if ($(window).width() < 360) {
                        location.href='mobile.php';
                }
        });

PHP is used on initial page load to identify mobile devices therefore, mobile.php is used for devices which are classified as mobile, index.php is used for all others. A query string parameter is used to override the detection, which allows the viewer to switch between the pages to best suit their needs.

eZ Publish - LiteSpeed - Conditional header

I have an eZ Publish site, running in host access mode, with three subdomains, admin, mobile and down.

I don’t want the subdomains to be indexed by search engines, and I especially want to keep the admin interface out of the search engines.

Somehow, the admin interface got into Google. I updated the robots.txt file, but that only prevents further scans, it won’t cause pages to be removed (http://support.google.com/webmasters/bin/answer.py?hl=en&answer=156449).

To prevent the subdomains from being listed in Google search results, I tried to use these directives on a LiteSpeed server:

Code

SetEnvIfNoCase Host ^(admin|mobile|down).*$ NO_INDEX=true
Header set X-Robots-Tag "noindex" env=NO_INDEX

It seems that the conditional always evaluates to true (or is ignored).

In that case, placing the Header directive in a VirtualHost section would be the best approach, but I don’t have access to the server configuration. I sent a ticket in to the hosting company, but they’re really busy. That’s a nice way of saying they didn’t fix it quickly.

One of the nicest features of eZ is its robust configuration, which includes HTTP headers. Adding these settings in siteaccess/admin/site.ini.append.php worked.

Code

[HTTPHeaderSettings]
CustomHeader=enabled
OnlyForAnonymous=enabled
HeaderList[]=X-Robots-Tag
X-Robots-Tag[]
X-Robots-Tag[/]=noindex, nofollow

To remove the admin interface from Google, I’ll send them a request, just to be sure.

Deny Access Based on Accept-Language Header

It can be difficult to determine the source of a request to a server. You can use geolocation to lookup an IP address, one excellent service is IP2Location.

However, if the goal is to deny access to servers from outside the country, language may be an effective identifier.

Whether the language is sent in the headers is an unknown, however, if it is sent, and it isn’t the language of the site, access can be denied with these commands:

In this case, if ‘en’ (English) is not one of the languages the client will accept, the request will be denied.

Code

RewriteCond %{HTTP:Accept-Language} !(en) [NC]
        RewriteRule ^.*  - [F]

This should be part of a layered strategy, with other rules ensuring additional protection.

A word of caution. If the Accept-Language header is missing, access will also be denied. Search engine bots often omit the Accept-Language header.

eZ Publish Workflow - electronic media delivery

I’m helping on a site that sells music.

I wanted a way to bundle a person’s purchases into a single file that could be downloaded once. There are two types of electronic media, mp3_product and album. Each may have an image associated with it. An mp3_product is an .mp3 files, an album is a .zip file, containing .mp3s.

The link above is a great resource. Using it, I created a custom workflow:

PHP

<?php
 
class CreateDownloadFileType extends eZWorkflowEventType
{
    const WORKFLOW_TYPE_STRING "createdownloadfile";
    public function __construct()
    {
        parent::__constructCreateDownloadFileType::WORKFLOW_TYPE_STRING'Create Download File');
    }
 
    public function execute$process$event )
    {
        $parameters $process->attribute'parameter_list' );
 
    $order_id $parameters['order_id'];
    $user_id $parameters['user_id'];
 
    $order eZShopFunctionCollection::fetchOrder ($order_id);
 
    $items eZProductCollectionItem::fetchListarray'productcollection_id' => $order['result']->ProductCollectionID ) );
 
    $order_number $order['result']->OrderNr;
 
    $zip_file='/home/account/www/download/'.md5($order_number.$user_id).'.zip';
    $zip = new ZipArchive();
    if ($zip->open($zip_file,ZIPARCHIVE::CREATE)!==true) {
        eZDebug::writeError('Order: '.$order_id.' - could not create zip file '.$zip_file);
    }
 
    $storageDir eZSys::storageDirectory();
    $attributes_to_include array('file','image');
 
    foreach ($items as $item)
    {
        $product eZContentObject::fetch($item->ContentObjectID);
 
        if (in_array($product->ClassIdentifier,array('mp3_product','album')))
        {
            $product_data $product->dataMap();
            $name preg_replace('/[^\w]/','_',trim($product_data['name']->DataText));
 
            foreach ($attributes_to_include as $v)
            {
                if ($product_data[$v]!==null)
                {
                    $attribute_data $product_data[$v]->Content();
                    $file $attribute_data->Filename;
                    $mime_type $attribute_data->MimeType;
                        list( $group$type ) = explode'/'$mime_type );
                    $filePath $storageDir '/original/' $group '/' $file;
                    $pathInfo pathinfo($filePath);
                    if ($zip->addFile($filePath,$name.'.'.$pathInfo['extension'])!==true) {
                        eZDebug::writeError('Order: '.$order_id.' - failed to add '.$filePath.' to '.$zip_file);
                    }
                }
            }
        }
        $zip->close();
    }
 
        return eZWorkflowType::STATUS_ACCEPTED;
    }
}
eZWorkflowEventType::registerEventTypeCreateDownloadFileType::WORKFLOW_TYPE_STRING'createdownloadfiletype' );
?>

The zip file created is named with an md5 of the order number and user id, and placed in a download directory. It’s probably not the most secure approach, but I think it is okay for this application, and it can be changed later.

This triggers on shop/checkout/after, which means the person has paid.

The download link for the zip file is placed in both the order view and order email templates.

Code

{def $current_user=fetch( 'user', 'current_user' )}
    {if $current_user.is_logged_in}
    <h2>{"Download"|i18n("design/base/shop")}:</h2>
    <p><a href="/download/{concat($order.order_nr,$current_user.contentobject_id)|md5()}.zip">{"Click to download"|i18n("design/base/shop")}</
a></p>
    {/if}
    {undef $current_user}

You may need an Alias and a RewriteRule:

Code

Alias download /home/account/www/download
RewriteRule ^download/.*\.zip - [L]

The final step was to create a mechanism to delete the files after they are downloaded. There are at least two ways to do this. The first approach is to use an Apache filter, with a bash script:

Apache configuration code:

Code

ExtFilterDefine delete mode=output
cmd="/home/account/webfilter.sh"
 
<Directory /home/account/www/download>
SetOutputFilter delete
</Directory>

Code

FILENAME="/home/account/www$DOCUMENT_URI";
cat -
if [ -e $FILENAME ]; then
        rm $FILENAME;
fi

The Apache filter code is courtesy of Lyrix, Inc..

If you can’t use a filter - which is likely if you are using shared hosting, you can also use a PHP script. This uses a RewriteRule to route the request through PHP.

Code

Options -Indexes
<IfModule mod_rewrite.c>
        RewriteEngine On
        RewriteRule ^(.*\.zip)$ zip.php?path=$1 [L]
</IfModule>

Finally, this is the PHP code that will deliver the file, then delete it.

PHP

<?php
// downloading a file and then deleting it
$filename $_GET['path'];
if (is_file($filename))
{
        // fix for IE catching or PHP bug issue
        header("Pragma: public");
        header("Expires: 0");
        header("Cache-Control: must-revalidate, post-check=0, pre-check=0");
        header("Cache-Control: public");
        header("Content-Description: File Transfer");
        // browser must download file from server instead of cache
 
        // force download dialog
        header("Content-Type: application/zip");
 
        // use the Content-Disposition header to supply a recommended filename and
        // force the browser to display the save dialog.
        header("Content-Disposition: attachment; filename=".basename($filename).";");
 
        /*
        The Content-transfer-encoding header should be binary, since the file will be read
        directly from the disk and the raw bytes passed to the downloading computer.
        The Content-length header is useful to set for downloads. The browser will be able to
        show a progress meter as a file downloads. The content-lenght can be determines by
        filesize function returns the size of a file.
        */
        header("Content-Transfer-Encoding: binary");
        header("Content-Length: ".filesize($filename));
 
        @readfile($filename);
        unlink($filename);
        exit();
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<title>Requested Content Not Available</title>
</head>
<body>
<h1>Requested Content Not Available</h1>
<p>The content you requested is not available.</p>
<p>If you were unable to download your purchases, please contact us for assistance.</p>
</body>
</html>

jQuery eZ publish Mobile pagelayout.tpl

This code has been updated - use the github link above to get the latest code.

Using the book Head First Mobile Web to learn and as a reference, I created a simple mobile interface for an existing eZ publish installation.

pagelayout.tpl

pagelayout.tpl includes three ‘pages’, content, search, and menu.

Code

{*?template charset=utf-8?*}
<!DOCTYPE html>
<html manifest="/manifest.appcache">
<head>
<meta NAME="robots" CONTENT="noindex,nofollow" />
<meta http-equiv="content-language" content="en" />
<meta http-equiv="content-type" content="text/html;charset=UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="stylesheet" href="http://code.jquery.com/mobile/1.1.0/jquery.mobile-1.1.0.min.css" />
<link rel="stylesheet" href="/design/mobile/stylesheets/core.css" />
<script src="http://code.jquery.com/jquery-1.6.4.min.js"></script>
<script src="http://code.jquery.com/mobile/1.1.0/jquery.mobile-1.1.0.min.js"></script>
{include uri="design:page_head.tpl" enable_link=false}
</head>
<body>
{default current_user=fetch('user','current_user')}
<div data-role="page" id="content">
        {include uri="design:header.tpl"}
        <div data-role="content">
                {$module_result.content}
        </div>
        {include uri="design:footer.tpl" page="content"}
        <div id="footer">
                <address>{"Powered by %linkStartTag eZ publish&reg; open source content management system %linkEndTag and development framework."|i18n("design/base",,hash('%linkStartTag',"<a href='http://ez.no'
>",'%linkEndTag',"</a>" ))}<br />Copyright &copy;  2006-{currentdate()|datetime('custom','%Y')} <b>{ezini('SiteSettings','MetaDataArray','site.ini').copyright}</b><br />
                </address>
        </div><!--/footer-->
</div><!--/content-->
<div data-role="page" id="search">
        {include uri="design:header.tpl"}
        <div data-role="content">
                {include uri="design:searchbox.tpl"}
        </div>
        {include uri="design:footer.tpl" page="search"}
</div><!--/search-->
<div data-role="page" id="menu">
        {include uri="design:header.tpl"}
        {cache-block keys=array('nav',$uri_string, $current_user.role_id_list|implode( ',' ), $current_user.limited_assignment_value_list|implode( ',' ))}
        <div data-role="content">
                {menu name="LeftMenu"}
        </div>
        {include uri="design:footer.tpl" page="menu"}
        {/cache-block}
</div><!--/menu-->
{/default}
</body>
</html>

There are several supporting templates.

searchbox.tpl

Code

{*?template charset=utf-8?*}
<form action={"/content/search/"|ezurl} method="get">
<h2>{"Search"|i18n("design/standard/toolbar/search")}</h2>
<input class="searchinput" type="text" name="SearchText" id="SearchText" value="" placeholder="{'Search'|i18n('/design/standard/toolbar/search')}">
<input type="submit" value="{'Submit'|i18n('/design/standard/node')}" data-icon="search" data-iconpos="left" />
</form>

header.tpl

Code

{*?template charset=utf-8?*}
<div data-role="header" data-position="fixed">
<a href="/" data-rel="back" data-icon="back" data-role="button">{'Back'|i18n('design/standard/node')}</a>
<h1>{'Example'|i18n('design/standard/node')}</h1>
<a href="http://example.com/?notmobile" data-rel="external" data-icon="home" data-role="button" data-theme="b" data-iconpos="right">{'Full Site'|i18n('design/st
andard/node')}</a>
</div>

footer.tpl

Code

{*?template charset=utf-8?*}
{if not(is_set($page))}
{def $page='unknown'}
{/if}
<div data-role="footer" data-position="fixed">
<div data-role="navbar">
<ul>
<li><a href="/Home" data-icon="home" data-role="button">{'Home'|i18n('design/standard/node')}</a></li>
{if $page|ne('menu')}
<li><a href="#menu" data-icon="menu" data-role="button">{'Menu'|i18n('design/standard/node')}</a></li>
{/if}
{if $page|ne('search')}
<li><a href="#search" data-icon="search" data-role="button">{'Search'|i18n('design/standard/node')}</a></li>
{/if}
</ul>
</div>
</div>

menu/flat_left.tpl

Code

{*?template charset=utf-8?*}
{let docs=treemenu( $module_result.path,
                    is_set( $module_result.node_id )|choose( 2, $module_result.node_id ),
                    ezini( 'MenuContentSettings', 'LeftIdentifierList', 'menu.ini' ),
                    0, 5 )
                    depth=1
                    last_level=0}
        <ul data-role="listview" data-inset="true">
        {section var=menu loop=$:docs last-value}
            {set last_level=$menu.last|is_array|choose( $menu.level, $menu.last.level )}
            {section show=and( $last_level|eq( $menu.level ), $menu.number|gt( 1 ) )}
                </li>
            {section-else}
            {section show=and( $last_level|gt( $menu.level ), $menu.number|gt( 1 ) )}
                </li>
                    {"</ul>
                </li>"|repeat(sub( $last_level, $menu.level ))}
            {/section}
            {/section}
 
            {section show=and( $last_level|lt( $menu.level ), $menu.number|gt( 1 ) )}
                {'<ul><li>'|repeat(sub($menu.level,$last_level,1))}
                <ul>
                    <li class="menu-level-{$menu.level}">
            {section-else}
                <li class="menu-level-{$menu.level}">
            {/section}
            {let menu_node=fetch('content','node',hash('node_id',$menu.id))}
              {if $menu_node.class_identifier|ne('link')}
                <a {$menu.is_selected|choose( '', 'class="selected"' )} href={$menu.url_alias|ezurl}>{$menu.text|shorten( 25 )}</a>
              {else}
                <a href={$menu_node.data_map.location.content|ezurl} target="_blank">{$menu.text|shorten( 25 )}</a>
              {/if}
            {/let}
            {set depth=$menu.level}
        {/section}
           </li>
 
        {section show=sub( $depth, 0 )|gt( 0 ) loop=sub( $depth, 0 )}
            </ul>
        </li>
        {/section}
        </ul>
 
{/let}

The site includes .mp3 files, so an HTML5 audio tag is included. The .mp3s are also offered as links for native device playback.

datatype/ezbinaryfile.tpl

Code

{default icon_size='normal' icon_title=$attribute.content.mime_type icon='no'}
{if $attribute.has_content}
{if $attribute.content}
{if $attribute.content.original_filename|ends_with('.mp3')}
<audio controls="controls">
        <source src="{$fileurl|ezurl('no','full')}" type="audio/mp3" />
        Audio playback not available.
</audio>
<div class="break"></div>
{/if}
{switch match=$icon}
    {case match='no'}
        <a href={concat("content/download/",$attribute.contentobject_id,"/",$attribute.id,"/file/",$attribute.content.original_filename)|ezurl}>{$attribute.content.original_filename|wash(xhtml)}</a> {$attribute
.content.filesize|si(byte)}
    {/case}
    {case}
        <a href={concat("content/download/",$attribute.contentobject_id,"/",$attribute.id,"/file/",$attribute.content.original_filename)|ezurl}>{$attribute.content.mime_type|mimetype_icon( $icon_size, $icon_tit
le )} {$attribute.content.original_filename|wash(xhtml)}</a> {$attribute.content.filesize|si(byte)}
    {/case}
{/switch}
{else}
        <div class="message-error"><h2>{"The file could not be found."|i18n("design/base")}</h2></div>
{/if}
{/if}
{/default}

core.css is included to support the navigation. Any other CSS required for proper page display could be added.

core.css

CSS

/* CORE CSS 20040217 */
 
/* BODY */
 
/* PAGE NAVIGATION */
 
div.pagenavigator
{
    text-align: center;    
}
 
div.pagenavigator span.previous
{
    float: left;
}
 
div.pagenavigator span.next
{
    float: right;
}
div#footer
{
    padding: 10px 0 10px 0;
    text-align: center;
}

I used eZ 4.7 (community edition equivalent), which includes auto device detection. This works extremely well.

Notes

Naming the mobile siteaccess and enabling autodetection in site.ini.


settings/override/site.ini.append.php

Code

[SiteAccessSettings]
CheckValidity=false
AvailableSiteAccessList[]=site
AvailableSiteAccessList[]=mobile
AvailableSiteAccessList[]=edit
ForceVirtualHost=true
MatchOrder=host
HostMatchType=map
HostMatchMapItems[]=domain.com;site
HostMatchMapItems[]=m.domain.com;mobile
HostMatchMapItems[]=edit.domain.com;edit
HostMatchMapItems[]=www.edit.domain.com;edit
HostMatchMapItems[]=www.domain.com;site
MobileSiteAccessList[]=mobile
MobileSiteAccessURL=http://m.domain.com
DetectMobileDevice=enabled

The manifest.appcache file is used to reduce bandwidth requirements and speed pages. In this case, the Home page, css, and jQuery are cached.

manifest.appcache

Code

CACHE MANIFEST
 
NETWORK:
content/search*
*
 
CACHE:
Home
design/mobile/stylesheets/core.css
http://code.jquery.com/mobile/1.1.0/jquery.mobile-1.1.0.min.css
http://code.jquery.com/jquery-1.6.4.min.js
http://code.jquery.com/mobile/1.1.0/jquery.mobile-1.1.0.min.js

You will probably need to add or update a RewriteRule to ensure proper delivery of the manifest.appcache file:

Code

RewriteRule ^(robots\.txt|favicon\.ico|manifest\.appcache)$ - [L]

It is a good idea to keep the mobile site out of search engines. This RewriteRule will allow you to deliver a different robots.txt file.

Excerpt from .htaccess

Code

RewriteCond %{HTTP_HOST} ^m\..*
RewriteRule ^robots\.txt$ m.robots.txt [L]

m.robots.txt

Code

User-agent: *
Disallow: /