Welcome to the sixth settlement of our tutorial series on how to build your own Multilingual MVC CMS from scratch and in which I will get into full detail as to how themes and languages are managed in our project.
During the
last settlement, I have demonstrated a way of building a user interface that allows us to register, sign in and review/update personal data. I have also challenged you, aided by all the knowledge you have gained so far, to try and build some additional features like forgotten password, password changing and extra settings modules on your own project, operation that you should not be so difficult by now and that should considerably enhance the interface you have started to build, if you have.
But today, I will be getting in a totally different aspect of the project, gathering all the previously discussed elements about theme and language handling and coming full circle by giving you a complete, synthesized overview of the matter. I will point out however, that this will not have any direct relation with the back-end language and theme management features we will be starting to create during the next settlements, as this will in fact only be a discussion about everything pertaining to theme/language handling, mostly in front end.
Language handling
So, starting with languages and starting with the obvious, all language files, whether used in back-end or in front-end, will fall under the same directory structure as shown right bellow
The file named encoding.php merely holds an array containing charsets on a per language basis, we will later review the method it is being useful to.
<?php
/* languages/encoding.php */
$encod = array(
'en' => 'utf-8',
'fr' => 'utf-8',
'uk' => 'utf-8',
'ru' => 'utf-8',
'de' => 'utf-8',
'nl' => 'utf-8',
'se' => 'utf-8',
'es' => 'utf-8'
);
?>
The file named translate.js contains a script that's needed to help localize all Javascript strings. Since I have already presented it in a previous article (
http://www.sitepoint.com/localizing-javascript-strings-php-mvc-framework/), I will not need to do so here.
Obvious is the fact that each language folder contains its own flag, in .png format.
One thing that differentiates the front-end section however, is that a template folder has been added to address template needs while sending out emails, eg: confirmation of registration, contact form and new password sending. Indeed, a user who has registered using Swedish may not like to receive a confirmation all written in English or in any language other than Swedish for that matter.
Let's now take the example of registration_en.php
<?php
/* languages/templates/en/registration_en.php */
$content = "
<html>
<head>
<title>Your registration</title>
</head>
<body>
<p>Thank you for your registration on \"".SITE_TITLE."\"!</p>
Your credentials are
<table>
<tr>
<td>Login : </td>
<th>".$login."</th>
</tr>
<tr>
<td>Pass : </td>
<th>".$password."</th>
</tr>
</table>
<p>
Regards,<br />
the site's team
</body>
</html>
";
?>
Knowing HTML, you can all imagine what the output is going to look like in your favorite email client program.
Interesting too is the constant named CONTACT_FORM_RECEIVER_LANGUAGE, which is used to define the language in which the administrator will like to receive its feedback emails.
define("CONTACT_FORM_RECEIVER_LANGUAGE","en"); // the code of the language in which you wish to receive the user contact messages
Now for the core language files that hold most of the PHP localized strings, let's take the example of en.php
<?php
/* from languages/en/en.php */
$t_welcome = 'Welcome';
$t_contact = 'Contact';
$t_registration = 'Register';
$t_reg = 'Register';
$t_copyright = 'Copyright';
$t_submit = 'Submit';
$t_save = 'Save';
...
?>
As an unspoken rule, localized PHP strings follow the format $t_my_word_or_sentence = 'My word or sentence'; Thus the variable name is always lowercase, each word separated by an underscore, and t_ is appended at the beginning of each term. HTML tags may be used inside the literal string part, constants too, but best is to avoid this whenever possible, for readability at least.
And now, time to get into the controversial debate about global variables. Since day 1, I have chosen to use global variables to access the translation strings we have just discussed. So, for example, throughout the whole site, we may access $t_welcome by using $GLOBALS['t_welcome'], which turns out to be greatly convenient, for a number of reasons.
Although... I can hear some of you screaming 'globals? DO NOT USE GLOBALS!', which is sometimes fair in its own right. But passed the fact that I myself am totally not convinced using globals for my translation system here will affect the project or anyone instantiating/using it, fact is, I have even yet to be convinced by the generally advised methods in the field, which are either about stacking everything into array from which translations are latter picked, or using POEdit/gettext which I don't feel truly answers the specific needs I have been having for this project so far.
So that, the array method being pretty cumbersome to maintain and great only for small localization projects and also, with over 1000 translation strings for each language, unless someone comes up with a great new way to handle localization that's easily and quickly implementable, even in large-scale environments, that's the method I will keep using. Besides, not only this is the one method I am myself being the most comfortable with, this is also a method that neither ever failed me nor made waves somewhere else in the project since I have started implementing it. So, as programmers too sometimes say: 'Don't repair it if it ain't broken!'.
But the truth is, within our project, there is much more to language handling than mere string localization.
Let's start from the localization table and then go on with the language selector bar
<?php
--
-- Table structure for table `localization`
--
CREATE TABLE `localization` (
`id_localization` smallint(6) NOT NULL,
`localization_name` varchar(3) NOT NULL,
`localization_flag` varchar(8) NOT NULL,
`localization_enabled` enum('no','yes') NOT NULL,
`localization_default` enum('no','yes') NOT NULL,
`localization_timestamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=MyISAM DEFAULT CHARSET=latin1;
--
-- Dumping data for table `localization`
--
INSERT INTO `localization` (`id_localization`, `localization_name`, `localization_flag`, `localization_enabled`, `localization_default`, `localization_timestamp`) VALUES
(7, 'se', 'se.png', 'no', 'no', '2014-10-24 19:17:26'),
(8, 'es', 'es.png', 'no', 'no', '2014-10-24 19:17:26'),
(5, 'ru', 'ru.png', 'no', 'no', '2014-10-24 19:17:26'),
(6, 'nl', 'nl.png', 'no', 'no', '2014-10-24 19:17:26'),
(3, 'fr', 'fr.png', 'yes', 'yes', '2014-10-24 19:17:26'),
(4, 'uk', 'uk.png', 'no', 'no', '2014-10-24 19:17:26'),
(1, 'en', 'en.png', 'yes', 'no', '2014-10-24 19:17:26'),
(2, 'de', 'de.png', 'no', 'no', '2014-10-24 19:17:26'),
(10, 'dk', 'dk.png', 'no', 'no', '2014-10-24 19:17:26');
--
-- Indexes for dumped tables
--
--
-- Indexes for table `localization`
--
ALTER TABLE `localization`
ADD PRIMARY KEY (`id_localization`);
--
-- AUTO_INCREMENT for dumped tables
--
--
-- AUTO_INCREMENT for table `localization`
--
ALTER TABLE `localization`
MODIFY `id_localization` smallint(6) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=11;
?>
Nothing too unexplainable about this one, although it's a good occasion to remind you the timestamp field is here only to help force MySQL perform an update of at least one row every time we perform an operation but the data hasn't changed.
And from this table, we can start building a language selector bar users will be able to interact with, shifting from one language to another easily.
<?php
/* from themes/your_style/main.php */
// we fetch an instance of the localization model
// and call the method that will retrieve all enabled languages
$lang_bar = LocModel::getInstance()->getData_language_bar();
foreach($lang_bar as $ln_item){//for each language
if(GRAPHICAL_LN_BAR == true){
// if flag view is set, we display the flag for each language
// wrapped in the target url
// The get_ln_straight() method from bellow
// will take in a URL and process it to match the language
// on which the mouse is hovering,
// all while preserving the other elements the current URL contains, as well as their order
?>
<a href="<?php echo LocModel::getInstance()->get_ln_straight($ln_item[0]); ?>"><img src="<?php echo CLEAN_PATH.'/'; ?>languages/<?php echo $ln_item[0]; ?>/<?php echo $ln_item[1]; ?>" width="<?php echo FLAG_WIDTH; ?>" height="<?php echo FLAG_HEIGHT; ?>" alt="<?php echo isset($GLOBALS['t_'.$ln_item[0].'_lng'])?$GLOBALS['t_'.$ln_item[0].'_lng']:"$ln_item[0]"; ?>" /></a> <?php
}
else{
?>
<a href="<?php echo LocModel::getInstance()->get_ln_straight($ln_item[0]); ?>"><u><?php echo $ln_item[0]; ?></u></a> <?php
}
}
}
?>
There is not much to write about the commented above code, expect for the call to the get_ln_straight() method, which will, no matter what the current URL is, make sure the language that's being displayed in the target link matches the flag/language name on which the mouse of the user is hovering, all while preserving the other elements the current URL contains, as well as their order. But before that, let's consider the useful getData_language_bar() method
<?php
/* from application/models/LocModel.php */
...
public function getData_language_bar(){
$this->locData = array();
$line = array();
// we first check what language directories (inside both front-end and back-end /language dirs)
//exist physically on server. We use PHP's glob() function for that
$language_directories = glob(PATH_TO_LANGUAGES . '/*' , GLOB_ONLYDIR);
$language_admin_directories = glob('admin/'.PATH_TO_LANGUAGES. '/*' , GLOB_ONLYDIR);
$admin_language_list = array();
foreach($language_admin_directories as $admin_dir){
$exp_admin_dir = explode("/",$admin_dir);
array_push($admin_language_list, end($exp_admin_dir));
}
$language_list = array();
foreach($language_directories as $dir){
$exp_dir = explode("/",$dir);
// if the currently processed front-end language dir can be found in the admin language dir array,
// we push it inside the $language_list array, to be used later for checking
if( in_array( end($exp_dir), $admin_language_list)){array_push($language_list, end($exp_dir));}
}
//then we pull out the language list from the db
$q_ln_bar = "SELECT id_localization, localization_name, localization_flag, localization_enabled
FROM localization
WHERE localization_enabled = 'yes'
ORDER BY id_localization ASC
";
MySQLModel::get_mysql_instance()->executeQuery($q_ln_bar);
while($mylnbar = MySQLModel::get_mysql_instance()->getRows($q_ln_bar)){
//we now compare the each freshly pulled language row from the DB
//with our array of check existing physical language dirs
if(in_array($mylnbar['localization_name'], $language_list)){
// so that, basically, if the name of the currently processed language corresponds to existing language folders
//in both back-end and front-end at the same time, we consider that language valid and stack it in an array,
//that will be stacked with other arrays in one more array that will be returned to us as a whole
array_push($line, $mylnbar['localization_name'],$mylnbar['localization_flag']);
array_push($this->locData,$line);$line = array();
}
}
return $this->locData;
}
...
?>
And then the get_ln_straight() method we have started to discuss earlier, here to ensure the user will always fall back on its feet while changing the language from any page
<?php
/* from application/models/LocModel.php */
...
public function get_ln_straight($lang){
//using $_SERVER['QUERY_STRING'] will make our life a lot easier when it comes to dealing with clean URLs
if(empty($_SERVER['QUERY_STRING'])){ // if $_SERVER['QUERY_STRING'] turns out empty we use default values
if(DEFAULT_PLINK){$plink = '&'.PLINK.'='.CURRENT_PLINK;} else{$plink = '';}
$_SERVER['QUERY_STRING'] = RLINK.'='.CURRENT_RLINK.$plink.'&'.LN.'='.CURRENT_LANG;
}
$this->query_string = $_SERVER['QUERY_STRING'];
$pat = '/'.LN.'=[a-z]{2}/';
$pat2 = '/language=[a-z]{2}/'; //that's for Google CSE
//if we can spot a valid URL language atom, we replace it by the then-sought language, $lang,
//initially passed as parameter to the method
if(preg_match($pat, $this->query_string, $matches, PREG_OFFSET_CAPTURE, 3)){
$rpl = LN.'='.$lang;
$this->query_string = preg_replace($pat, $rpl, $this->query_string);
}
if(preg_match($pat2, $this->query_string, $matches, PREG_OFFSET_CAPTURE, 3)){
$rpl2 = 'language='.$lang;
$this->query_string = preg_replace($pat2, $rpl2, $this->query_string);
}
//normally at that point, we should already be fine,
//but clean urls come complicating everything again...
if(CLEAN_URLS == true){//if clean urls are on,
//we don't just preg_replace our query string, we explode it.
$exp_query_string = explode('&', $this->query_string);
$markers = array(
//and here come markers
//basically, markers are something we use with clean_urls whenever thought necessary,
//to avoid collision between .htaccess rewrite rules, when there are (too) many of them
'pn' => PGN,
//note that 'pn' is what will be written in .htaccess
//and PGN is the constant name that normally holds the page number url variable 'pgn'.
// again, with clean urls being off, you could assign any other value than pgn to the constant PGN
'sn' => SN
);
$fake_string = array(); //this will hold our new url string until imploded
foreach($exp_query_string as $atom){ //for each atom of the old string (x=y)
$exp_atom = explode("=",$atom); // we explode it (the atom)
$needle = $exp_atom[0]; //and take its left part, called the needle in this case
if(!preg_match('/success/',$atom)){// we avoid the word success in our algorithm for collision reasons
//we then search for that needle inside the haystack (marker's array)
if(in_array($needle, $markers) ){
//if found, we will create a new clean atom with the marker in it.
//because that's what markers are here for - making clear what the nature of the value they bare is
$key = array_search($needle, $markers); //so we get its key
$new_atom = $key.'/'.$exp_atom[1]; //and actually create the new 'clean' atom using that key,
//a slash sign and the value of the old one
}
else{$new_atom = $exp_atom[1];} // otherwise, if the atom does not need a marker, we retain only the x part of the old string
array_push($fake_string,$new_atom); //and we stack each resulting atom in our $fake_string array
}
}
///and then we turn that array to a string, using '/' to implode all parts
$new_string = implode("/", $fake_string);
//we quickly append our clean path constant and we're good to go
$this->query_string = CLEAN_PATH.'/'.$new_string;
//not just yet though... in case of Google CSE
if(preg_match('/cx=(.*)&gs=(.*)&sa.x=(.*)&sa.y=(.*)/', $_SERVER['REQUEST_URI'])){
// so, if google search is on
//we explode not the query string but the request URI this time
$exp_q_s = explode('?',$_SERVER['REQUEST_URI']);
$this->query_string .= '?'.$exp_q_s[1]; //and retain only the parts proper to Google,
//(so the user keeps seeing its search results after it has selected another language),
//we concatenate them to the query string we've just built and now we're good to go.
}
}
else{
//else, if clean urls weren't set, we only need to append $_SERVER['PHP_SELF']
//instead of the clean path constant before the new string we have built
$this->query_string = $_SERVER['PHP_SELF'].'?'.$this->query_string;
}
return $this->query_string; //we return our resulting string
}
?>
Then still from the localization model, the get_encoding_straight() method, which if passed the current language as parameter, will return the corresponding charset, as defined in languages/encoding.php. This method will be used in header.php, both in front-end and back-end. By the way, we will get to the header files today while getting in the subject of theme handling, so we will have a chance to see the get_encoding_straight() method in action there.
<?php
/* from application/models/LocModel.php */
...
public function get_encoding_straight($lang){
require PATH_TO_LANGUAGES.'/encoding.php'; // we require the file that contains the $encod array, as discussed earlier today.
if (array_key_exists($lang,$encod)){ //if we can find the current language in that array, as key
$this->encoding = $encod[$lang]; we then use its corresponding value
}
else{$this->encoding = 'utf-8';} //else we default to utf-8
return $this->encoding; //we return what we have then
}
?>
Finally, a word about the choice of character sets though: while this may feel odd to some of you that I sometimes modify the MySQL charset to Latin1 before attempting a query, from the way the database tables for this project were originally set up (latin1_swedish_ci), plus using utf-8 as HTML charset on all pages, no encoding-related display issues have yet arisen while testing the system against languages such as French, Russian or even Swedish, this on both Windows/*nix platforms. In other words, why changing what already works? But still, if you somehow end up getting encoding issues that I myself do not get, just try other values to see what works for you, although, everyone should be just fine with the settings as they currently are, regardless of the (modern) browser it's being displayed in.
Theme handling
At the beginning of these series, I have presented the template engine that dynamically includes all the necessary view files, plus takes care off the passing of variables from the controller. But there's of course more to theme handling than this powerful routine. I justify:
Let's start by analyzing the overall structure of the theme folder
Then the same structure in more detail
From this picture above, you can see there are two different styles being operated, which are named skane and sparta. These folders exist in the back-end section too but vary in content.
At root (/themes/your_style), you can find all the view files proper to each module and not only. There lie our common template files header.php, main.php and footer.php and we can also find secondary view files like vf_image_u.php or follow.php, which respectively create an image for the captcha in registration.php and create a social media follow bar to be included wherever found stylish inside main.php. More interesting view files can be found there, like paging.php but we will get to them during the forthcoming settlements only.
Jumping straight into the css folder, we find name_of_the_style.css.php which is the main css file for any given theme. It bares a .php extension because on some occasions we need to use PHP variables there. We also find specific CSS files for the libraries we use like dTree, which has dtree.css sitting there and so on.
On the same level as the css folder, we find a folder called images. That folder holds two sub-folders called favicon and servicing. While it looks obvious what the favicon folder is here for, the servicing folder however requires a little explanation. It is a folder that holds images that will not only build on the visual identity of a given theme, but that are also dedicated to common navigation items. As an example, servicing is the folder where you will find small arrows, warning and missing page/module icons. And since menus are considered standard navigation items, you will also find two sub-folders called dtree and hmenu, which contain images for use with the menu system you already know of. In fact a third sub-folder to the servicing folder can even be found, called rss, which contains a few rss feed logo .png format images to choose from (see RSS_ICON_FILE in config.php).
At the root level of /themes/your_style/images, therefore at the same level where the favicon and servicing folder are, can main logo images be found.
Entering the /themes/your_style/css folder again, we find there's an image folder too, not to be confused with /themes/your_style/images. This will contain images for use in Javascript dialog boxes. Note these dialog boxes are also configurable via /themes/your_style/css/dialog_box.css.
Back at the root level of the themes folder reside two of our common template files. The first one is header.php
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<?php
// here we sort out language code names to be compatible with most standards
if(CURRENT_LANG){
if(CURRENT_LANG == 'uk'){$tinymce_lang = 'uk_UA';}
elseif(CURRENT_LANG == 'fr'){$tinymce_lang = 'fr_FR';}
elseif(CURRENT_LANG == 'se'){$tinymce_lang = 'sv_SE';}
else{$tinymce_lang = CURRENT_LANG;}
}
else{$tinymce_lang = 'en';} // if nothing was found, we default to English
?>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="<?php echo $tinymce_lang; ?>" lang="<?php echo $tinymce_lang; ?>">
<head>
<?php
$header_utils = new UtilsModel();
$met = $header_utils->get_meta_info(); //we fetch meta infos from the DB, this is being discussed further down the page.
foreach($met as $m){ //for each meta entry...
if($m[0] == 'title'){ // if tag is title
echo '<title> ';
if(isset($page_name)){echo $page_name;}
else{
if(isset($module_name)){
echo isset($GLOBALS['t_'.strtolower($module_name)]) ? $GLOBALS['t_'.strtolower($module_name)] : $module_name;
}
}
echo '</title>';
}
else{ //if not title tag, we use the rest of what we have pulled from the meta table.
if($m[0] == 'http-equiv'){
if($m[1] == "Content_Type"){$m[1] = "Content-Type";}
$char = " charset = ".LocModel::getInstance()->get_encoding_straight(CURRENT_LANG)."";
$m[2] .= $char;
}
echo '<meta '.$m[0].'="'.$m[1].'" content="'.$m[2].'" />';
}
}
?>
<!-- now we link all the CSS/JS we need on our project -->
<link rel="shortcut icon" href="<?php echo CLEAN_PATH.'/'.PATH_TO_THEMES; ?>/<?php echo CURRENT_THEME; ?>/images/favicon/favicon.ico" type="image/x-icon" />
<link rel="stylesheet" href="<?php echo CLEAN_PATH.'/'.PATH_TO_THEMES; ?>/<?php echo CURRENT_THEME; ?>/css/<?php echo CURRENT_THEME; ?>.css.php" type="text/css" />
<link rel="stylesheet" type="text/css" href="<?php echo CLEAN_PATH.'/'.PATH_TO_THEMES; ?>/<?php echo CURRENT_THEME; ?>/css/dtree.css" />
<link rel="stylesheet" type="text/css" href="<?php echo CLEAN_PATH.'/'.PATH_TO_THEMES; ?>/<?php echo CURRENT_THEME; ?>/css/hmenu.css" />
<link rel="stylesheet" type="text/css" href="<?php echo CLEAN_PATH.'/'.PATH_TO_THEMES; ?>/<?php echo CURRENT_THEME; ?>/css/dialog_box.css" />
<link rel="stylesheet" type="text/css" href="<?php echo CLEAN_PATH.'/'.PATH_TO_THEMES; ?>/<?php echo CURRENT_THEME; ?>/css/dtp.css" />
<script src="<?php echo CLEAN_PATH.'/'; ?>languages/<?php echo CURRENT_LANG; ?>/JS/<?php echo CURRENT_LANG; ?>.js" type="text/javascript"></script>
<script src="<?php echo CLEAN_PATH.'/'; ?>languages/translate.js" type="text/javascript"></script>
<?php
include 'public/js/main.php';
include 'public/js/dtree.php';
?>
<script src="<?php echo CLEAN_PATH.'/'; ?>/public/js/hmenu.js" type="text/javascript"></script>
<?php include 'public/js/dialog_box.php'; ?>
<script src="<?php echo CLEAN_PATH.'/'; ?>public/js/dtp.js"type="text/javascript"></script>
<!-- and in case Javascript is disabled, we use a noscript tag -->
<noscript><div class="disabled_feature"><?php echo $GLOBALS['t_javascript_has_been_disabled']; ?> <?php echo $GLOBALS['t_please_reenable_it']; ?> - <a href="http://www.enable-javascript.com" target="_blank"><u><?php echo $GLOBALS['t_instructions_here']; ?></u></a></div>
</noscript>
</head>
So, in header.php, we first sort out our language code names, for enhanced compatibility with the most standards officially in use and we use the freshly defined code in our html xml declaration. Then we pull all meta info from the DB table called meta, that looks as follows
--
-- Table structure for table `meta`
--
CREATE TABLE `meta` (
`id_meta` tinyint(4) NOT NULL,
`meta_type` varchar(12) NOT NULL,
`meta_name` varchar(40) NOT NULL,
`meta_value` text NOT NULL
) ENGINE=MyISAM DEFAULT CHARSET=latin1;
--
-- Dumping data for table `meta`
--
INSERT INTO `meta` (`id_meta`, `meta_type`, `meta_name`, `meta_value`) VALUES
(1, 'title', 'title', 'Gumbo-CMS'),
(2, 'name', 'description', 'Gumbo (or Gumbo-CMS) is a Mutilingual MVC CMS written \'from scratch\' in PHP 5 and that requires Apache 2.2+ and MySQL 5.6+'),
(3, 'name', 'keywords', 'cms, mvc, php, multilingual cms'),
(4, 'name', 'robots', 'index, follow'),
(5, 'name', 'google_bot', 'index, follow'),
(6, 'name', 'google', 'notranslation'),
(7, 'http-equiv', 'Content_Type', 'text/html;');
--
-- Indexes for dumped tables
--
--
-- Indexes for table `meta`
--
ALTER TABLE `meta`
ADD PRIMARY KEY (`id_meta`);
--
-- AUTO_INCREMENT for dumped tables
--
--
-- AUTO_INCREMENT for table `meta`
--
ALTER TABLE `meta`
MODIFY `id_meta` tinyint(4) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=8;
and we start looping the results we have successfully fetched.
A note about meta tags though, please visit
https://support.google.com/webmasters/answer/79812?hl=en for a brief overview of the tags Google Inc. understands.
Then we go onto including all needed CSS/Javascript files and end up using a noscript tag that will warn users in case Javascript is disabled on their viewing device. That's all there is to the header file found in the front-end. In a next settlement though, we will see the header file for the back-end contains a lot more information, primarily due to TinyMCE being initialized there. Note that header.php ends with the closing head tag.
Meanwhile, in footer.php, the starting tag is a tr tag and the closing tag is the closing html tag
<?php
/* themes/your_style/footer.php */
?>
<tr class="footer_tr">
<td colspan="2" class="footer">
<?php echo $GLOBALS['t_copyright'].' '.COPYRIGHT; ?>
</td>
</tr>
</table>
</div>
</body>
</html>
Nothing too difficult about this one as well, and that wraps up this settlement in which you have learned more about the way languages and themes are dealt with in this project, getting for now a complete overview, for at least the front-end part.
But next time, I will take these series to yet another level, getting onto the other side of the fence by introducing you to the back-end side of our CMS, starting with the building of a really exciting language management module. Creating this new module will allow me, among other things, to tell you more about the paging and sorting features many of the back-end modules actually depend on.
This article first appeared Thursday the 16th of February 2016 on RolandC.net.