Wednesday, May 22, 2013

Runtime performance friendly AngularJs localization / translation using buildscript and Yii Framework + Invalidating the browser cache

Lately I have been working on a new web based single page application. The technologies and frameworks in use are:
One of the design challenged were - how do we approach webpage localization?

The following requirements were given:

  • It should incur as small run time overhead as possible, preferably none
  • The localizable strings should be maintained in text files to allow for external translators, without the overhead of having a full translation system installed and configured
  • The translation system should support pluralization

The solution that were chosen is a deployment/build step implemented in the Yii Framework that both translates and statically renders AngularJS views. The translation engine that is used is also included in the Yii Framework.

The below image shows a module "logbook" and the views needed for some simple CRUD functionality. This image from the filesystem is from the repository (i.e. before the build script has run).



The image below shows how the filesystem looks after the buildscript has run (at the hosting server)


As we can see the build script has added a directory for each supported language with the 2 letter language abbreviation according to ISO 639-1.

Lets have a look at first the english and then the danish version of the actual view when it is presented on the browser screen.


Lets have a look at the html for the angular view.

<div class="span12" id="logbook_edit" ng-controller="logbook.EditController">
    <div id="content">
        <h1><?=LogbookModule::t('Log');?></h1>
        <div ng-include src="'assets/__REVNO__/app/views/'+language+'/logbook/logbook_edit_toolbar.htm'"></div>    
        <form id="frm_logbook_edit" data-id="{{log.id}}" class="form-horizontal">
        <p class="note"><?=Yii::t('sitewide',"msg_field_with_asterisk_are_required");?></p>
        <div class="control-group">
            <label class="control-label required" for="logdate"><?=LogbookModule::t('Date');?></label>
            <div class="controls">
                <input type="text" id="logdate" name="logdate" ng-model="log.logdate" bs-datepicker data-date-format="Yii.user.preferred_date_format.toString().toLowerCase()" />
                <span class="help-inline"></span>
            </div>
        </div>
        <div class="control-group">
            <label class="control-label required" for="title"><?=LogbookModule::t('Title');?></label>
            <div class="controls">
                <input type="text" name="title" id="title" ng-model="log.title">
                <span class="help-inline"></span>
            </div>
        </div>        
        <div class="control-group">
            <label class="control-label required" for="mood"><?=LogbookModule::t('Mood');?></label>
            <div class="controls">
                <div class="btn-group mood-button-group" ng-model="log.mood" data-toggle="buttons-radio" bs-buttons-radio>
                  <button type="button" value="happy" class="btn happy" style="background-image:url(assets/__REVNO__/app/img/happy-icon.png);"></button>
                  <button type="button" value="sad" class="btn sad" style="background-image:url(assets/__REVNO__/app/img/sad-icon.png);"></button>
                  <button type="button" value="angry" class="btn angry" style="background-image:url(assets/__REVNO__/app/img/angry-icon.png);"></button>
                  <button type="button" value="ill" class="btn ill" style="background-image:url(assets/__REVNO__/app/img/ill-icon.png);"></button>
                </div>                
                <input type="hidden" id="mood" name="mood">
                <span class="help-inline"></span>
            </div>
        </div>
        <div class="control-group">
            <label class="control-label required" for="note"><?=LogbookModule::t('Note');?></label>
            <div class="controls">
                <textarea cols="" rows="" id="note" name="note" ng-model="log.note"></textarea>
                <span class="help-inline"></span>
            </div>
        </div>
        <div ng-include src="'assets/__REVNO__/app/views/'+language+'/logbook/logbook_edit_toolbar.htm'"></div>
        </form>
    </div>
</div>

There are a few things that are special in this HTML code
  •  "__REVNO__" placeholder which at buildscript run time is replaced by the actual revision identifier from the source code repository. This allows us to optimize the content headers, for maximum browser caching while at the same time ensuring that the end user gets the latest version when we update to a new version on the server. Controlled cache invalidation/busting
  •  "<?=LogbookModule::t('Note');?>" is the Yii Framework translation method/function t();
  • the angular scope variable "language" that is part of the logged in users context, this is represented by an 2 letter language abbreviation.
Lets have a look at how English translation string file look (standard Yii Framework messages file)

<?php
return array(
    'Date'=>'Date',
    'Title'=>'Title',
    'Mood'=>'Mood',
    'Note'=>'Note',
    'Log'=>'Log',
    'Edit'=>'Edit',
    'Delete'=>'Delete',
    'Create'=>'Create',
    'List'=>'List',
    'Save'=>'Save',
    'Logbook'=>'Logbook',
    'Created_by'=>'Created by',
);

and then how the translated view ends up looking after running the buildscript

<div class="span12" id="logbook_edit" ng-controller="logbook.EditController">
    <div id="content">
        <h1>Log</h1>
        <div ng-include src="'assets/1932/app/views/'+language+'/logbook/logbook_edit_toolbar.htm'"></div>    
        <form id="frm_logbook_edit" data-id="{{log.id}}" class="form-horizontal">
        <p class="note">Fields with <span class="required">*</span> are required.</p>
        <div class="control-group">
            <label class="control-label required" for="logdate">Date</label>
            <div class="controls">
                <input type="text" id="logdate" name="logdate" ng-model="log.logdate" bs-datepicker data-date-format="Yii.user.preferred_date_format.toString().toLowerCase()" />
                <span class="help-inline"></span>
            </div>
        </div>
        <div class="control-group">
            <label class="control-label required" for="title">Title</label>
            <div class="controls">
                <input type="text" name="title" id="title" ng-model="log.title">
                <span class="help-inline"></span>
            </div>
        </div>        
        <div class="control-group">
            <label class="control-label required" for="mood">Mood</label>
            <div class="controls">
                <div class="btn-group mood-button-group" ng-model="log.mood" data-toggle="buttons-radio" bs-buttons-radio>
                  <button type="button" value="happy" class="btn happy" style="background-image:url(assets/1932/app/img/happy-icon.png);"></button>
                  <button type="button" value="sad" class="btn sad" style="background-image:url(assets/1932/app/img/sad-icon.png);"></button>
                  <button type="button" value="angry" class="btn angry" style="background-image:url(assets/1932/app/img/angry-icon.png);"></button>
                  <button type="button" value="ill" class="btn ill" style="background-image:url(assets/1932/app/img/ill-icon.png);"></button>
                </div>                
                <input type="hidden" id="mood" name="mood">
                <span class="help-inline"></span>
            </div>
        </div>
        <div class="control-group">
            <label class="control-label required" for="note">Note</label>
            <div class="controls">
                <textarea cols="" rows="" id="note" name="note" ng-model="log.note"></textarea>
                <span class="help-inline"></span>
            </div>
        </div>
        <div ng-include src="'assets/1932/app/views/'+language+'/logbook/logbook_edit_toolbar.htm'"></div>
        </form>
    </div>
</div>

Now on to the actual build script which is implemented as a Yii CConsoleCommand

<?php
class BuildReleaseCommand extends CConsoleCommand
{
    var $careSupportedLanguages = array ('EN','DA');
    var $packageRevisionInSvn;
    
    public function actionBuild($siteDirectory, $subversionRepoUrl='https://your_repo_url_here', $svnCheckout=0) 
    {
        $packageRevisionInSvn = $this->getLatestRevisionNumberInSvn($subversionRepoUrl);
        $this->packageRevisionInSvn = $packageRevisionInSvn;
        
        // Delete same revsion folder to replace with newer one
        `rm -rf $siteDirectory/assets/$packageRevisionInSvn`;
        
        // Create php file to store revision number, it will be used in all php file where revision number is required
        $revisionFileContent = '<?php $revision = '.$packageRevisionInSvn.'; ';
        file_put_contents("$siteDirectory/revision.php", $revisionFileContent);

        if($svnCheckout){
            $this->checkoutLatestVersionFromSvn($subversionRepoUrl, $siteDirectory);
        }
        $this->insertRevisionNumberInFolderNames($siteDirectory, $packageRevisionInSvn);
    
        // Delete all directory other then en& da in $siteDirectory/assets/{$this->packageRevisionInSvn}/app/views/
        `rm -rf $siteDirectory/assets/{$this->packageRevisionInSvn}/app/views/*`;
        
        foreach($this->careSupportedLanguages as $key=>$language) {
            Yii::app()->language=strtolower($language);
            $this->translateViews($siteDirectory.'/assets/__REVNO__/app/views', $siteDirectory. '/assets/'.$this->packageRevisionInSvn.'/app/views');
        } 

        $this->insertRevisionNumberInViewUrls($siteDirectory.'/assets/'.$packageRevisionInSvn, $packageRevisionInSvn);
    }

    private function getLatestRevisionNumberInSvn($packageUrl) {
        return trim(`svn info --non-interactive --username YOUR_USERNAME --password YOUR_PASSWORD $packageUrl | grep 'Last Changed Rev' | head -1 | grep -Eo "[0-9]+"`);
    }

    private function checkoutLatestVersionFromSvn($packageUrl, $svnExportDirectory){
        $output = `svn export --force --non-interactive --username YOUR_USERNAME --password YOUR_PASSWORD $packageUrl $svnExportDirectory`;
        $output = `cd $svnExportDir$package`;
    }

 /**
 * Recurse through the filessystem to process all view html files
 * Feature: Translation 
 */
    private function translateViews($srcPath, $destPath){
        $ignoreFiles = array( '.', '..' ); 
        $dh = @opendir( $srcPath ); 
        while( false !== ( $file = readdir( $dh ) ) ){ 
            if( !in_array( $file, $ignoreFiles) ){ 
                if( is_dir( "$srcPath/$file" ) ){ 
                    $this->translateViews( "$srcPath/$file", "$destPath/$file" ); 
                } else { 
                    if (preg_match('/^.*\.(htm)$/i',$file)) {
                        $translatedView = $this->translateView("$srcPath/$file");
                        $this->saveTranslatedView("$destPath/$file", $translatedView);
                    }
                } 
            } 
        } 
        closedir( $dh ); 
    } 

 /**
 * Actual translation of the html view file
 * Feature: Translation 
 */
    private function translateView($fileFullPath){
        $module = $this->getModuleName($fileFullPath);
        $controller = new CController($module,new CWebModule($module,null));
        return $controller->renderInternal($fileFullPath,null,true);
    }

 /**
 * Save the processed view file under the ISO 639-1 2 letter language code directory
 * Feature: Translation 
 */
    private function saveTranslatedView($fileFullPath, $translatedView) {
        $translatedViewFile = str_replace('views','views/'.Yii::app()->language, $fileFullPath);
        $saveDir = dirname($translatedViewFile);
        if (!file_exists($saveDir)){
            mkdir($saveDir,0755, true);
        }
        file_put_contents($translatedViewFile, $translatedView);
    }

    /**
 * Replace the __REVNO__ placeholder in urls in the Angular views so it points to the correct revision number
 * Feature: Cachebusting
 */
 private function insertRevisionNumberInViewUrls($svnExportDirectory, $packageRevisionInSvn) {
        $output = `find $svnExportDirectory \( -iname "*.htm" -or -iname "*.js" -or -iname "*.php" \) -print | xargs sed -i 's/__REVNO__/$packageRevisionInSvn/g'`;
    }

    /**
 * Copy and rename the __REVNO__ folder to the actual revision number we are parsing
 * Feature: Cachebusting
 */
 private function insertRevisionNumberInFolderNames($svnExportDirectory, $packageRevisionInSvn) {
        $output = `cp -Rf $svnExportDirectory/assets/__REVNO__  $svnExportDirectory/assets/$packageRevisionInSvn`;
    }

    public function getModuleName($viewPath)
    {
        $dirs = explode("/", $viewPath);
        $passedViews = false;
        $module = "";
        foreach ($dirs as $key=>$dir)
        {
            if ($passedViews)
            {
                $module = $dir;
                return $module;
            }
            if ($dir == "views") {
                $passedViews = true;
                continue;
            }    
        }   
    }

}
?>
The script is commented to a fair degree, suffice to say that I am using the internal view renderer in the Yii Framework to do the heavy lifting for the translation, it knows where to find the language string files and to do the translation and pluralization. 

I am using the build script together with migration for auto-deployment to our test server like this:


cd /YOUR_WEBSITE_DIR; svn update --username YOUR_USERNAME --password YOUR_PASSWORD; chown -R apache:apache /YOUR_WEBSITE_DIR; protected/yiic migrate --interactive=0; protected/yiic buildrelease build --siteDirectory=/YOUR_WEBSITE_DIR/