CMS Design

Bibliographic Information

CMS Design Using PHP and jQuery

By: Kae Verens;
Publisher: Packt Publishing
Pub. Date: December 9,2010
Print ISBN-13: 978-1-84951-252-7

The admin area

There are a number of ways that administration of the CMS's database can be done:

  1. Pages could be edited "in-place". This means that the admin would log into the public side of the site, and be presented with an only slightly different view than the normal reader. This would allow the admin to add or edit pages, all from the front-end.
  2. Administration can be done from an entirely separate domain (admin.example.com, for example), to allow the administration to be isolated from the public site.
  3. Administration can be done from a directory within the site, protected such that only logged-in users with the right privileges can enter any of the pages.
  4. The site can be administrated from a dedicated external program, such as a program running on the desktop of the administrator.

     

Database tables

To record the users in the database, we need to create the user_accounts table, and the groups table to record the roles (groups).

First, here is the user_accounts table. Enter it using phpMyAdmin, or the console:

CREATE TABLE `user_accounts` (
`id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT ,
`email` text,
`password` char(32) DEFAULT NULL,
`active` tinyint DEFAULT '0',
`groups` text,
`activation_key` varchar(32) DEFAULT NULL,
`extras` text,
PRIMARY KEY (`id`)
) DEFAULT CHARSET=utf8;

Name Description
id This is the primary key of the table. It's used when a reference to the user needs to be recorded.
email You can never tell how large an e-mail address should be, so this is recorded as a text field.
password This will always by 32 characters long, because it is recorded as an MD5 string.
active This should be a Boolean (true/false), but there is no Boolean field in MySQL. This field says whether the user is active or disabled. If disabled, then the user cannot log in.
groups This is a text field, again, because we cannot tell how long it should be. This will contain a JSON-encoded list of group names that the user belongs to.
activation_key If the user forgets his/her password, or is registering for the first time, then an activation key will be sent out to the user's e-mail address. This is a random string which we generate using MD5.
extras When registering a user, it is frequently desired that a list of extra custom fields such as name, address, phone number (and so on) also be recorded. This field will record all of those using JSON. If you prefer, you could call this "usermeta", and adjust your copy of the code accordingly.

Note the usage of JSON for the groups field (or "column", if you prefer that word). Deciding whether to fully normalize a database, or whether to combine some values for the sake of speed, is a decision that often needs to be made.

In this table, I've decided to combine the groups into one field for the sake of speed, as the alternative is to use three table (the user_accounts table, the groups table, and a linking table), which would be slower than what we have here.

If in the future, it becomes necessary to separate this out into a fully normalized database, a simple upgrade script can be used to do this.

For now, populate the table with one entry for yourself, so we can test a login. Remember that the password needs to be MD5-encoded.

Note that MD5, SHA1&;, and other hashing functions are all vulnerable to collision-testing. If a hacker was to somehow get a copy of your database, it would be possible to eventually find working passwords for each MD5 or SHA1 hash. Of course, for this to happen, the hacker must first break into your database, in which case you have a bigger problem.

Whether you use SHA1, MD5, bcrypt, scrypt, or any of the other hashing functions is a compromise between your need for security (bcrypt being more secure), or speed (MD5 and SHA1 being fast).

Here's an example insert line:

insert into user_accounts
(email,password,active,groups)
values(
'kae@verens.com',
md5('kae@verens.com|my password'),
1,
'["_superadministrators"]'
)
;

Notice that the groups field uses JSON.

If we used a comma-delimited text field, then that would make it impossible to have a group name with a comma in it. The same is true of other character delimiters.

Also, if we used integer primary key references (to the groups table) then it would require a table join, which takes time.

By putting the actual name of the group in the field instead of a reference to an external table row, we are saving time and resources.

The password field is also very important to take note of.

We encrypt the password in the database using MD5. This is so that no one knows any user's password, even the database administrator.

However, simply encrypting the password with MD5 is not enough. For example, the MD5 of the word password is 5f4dcc3b5aa765d61d8327deb882cf99. This may look secure, but when I run a search for that MD5 string in a search engine, I get 28,300 results!

This is because there are vast databases online with the MD5s of all the common passwords.

So, we "salt" the MD5 by adding the user's e-mail address to it, which causes the passwords to be encrypted differently for each user, even if they all use the same password.

This gets around the problem of users using simple passwords that are easily cracked by looking up the MD5. It will not stop a determined hacker who is willing to devote vast resources to the effort, but as I said earlier, if someone has managed to get at the database in the first place, you probably have bigger problems.

Page Management

The given code shows the commonly changed details of the page database table. They include:

  • name

  • title

  • type

  • parent

  • associated_date

  • body

We'll enhance a few of those after we've finished the form. For now, a few things should be noted about the form and its options.

  • URL: When you are editing a page, it's good to view it in another window or tab. To do this, we provide a link to the front-end page. Clicking on the link opens a new tab or window.

  • Type: The page type by default is "normal", and in the select-box we built previously, that is the only option. We will enhance that when we get to plugins in a later chapter.

  • Parent: This is the page which the currently edited page is contained within. In the earlier form, we display only the current parent, and don't provide any other options. There's a reason for that which we'll explain after we finish the main form HTML.

  • Associated date: There are a number of dates associated with a page. We record the created and last-edited date internally (useful for plugins or logging), but sometimes the admin wants to record a date specific to the page. For example, if the page is part of a news system, we will enhance this date input box after the form is completed.

  • Body: This is the content which will be shown on the front-end. It's plain HTML. Of course, writing HTML for content is not a task you should push on the average administrator, so we will enhance that.

Dates

Dates are annoying. The scheme I prefer is to enter dates the same way MySQL accepts them — yyyy-mm-dd hh:mm:ss. From left to right, each subsequent element is smaller than the previous. It's logical, and can be sorted sensibly using a simple numeric sorter.

Unfortunately, most people don't read or write dates in that format. They'd prefer something like 08/07/06.

Dates in that format do not make sense. Is it the 8th day of the 7th month of 2006, or the 7th day of the 8th month of 2006, or even the 6th day of the 7th month of 2008? Date formats are different all around the world.

Therefore, you cannot trust human administrators to enter the dates manually.

A very quick solution is to use the jQuery UI's datepicker plugin.

Temporarily (we'll remove it in a minute) add the highlighted lines to /ww.admin/pages/pages.js:

other_GET_params:currentpageid
});
$('.date-human').datepicker({
'dateFormat':'yy-mm-dd'
});
});

It's a great calendar, but there's still a flaw: Before you click on the date field, and even after you select the date, the field is still in yyyy-mm-dd format.

While MySQL will thank you for entering the date in a sane format, you will have people asking you why the date is not shown in a humanly readable way.

We can't simply change the date format to accept something more reasonable such as "May 23rd, 2010", because we would then need to ensure that we can understand this on the server-side, which might take more work than we really want to do.

So we need to do something else.

The datepicker plugin has an option which lets you update two fields at the same time. This is the solution — we will display a dummy field which is humanly readable, and when that's clicked, the calendar will appear and you will be able to choose a date, which will then be set in the human-readable field and in the real form field.

Don't forget to remove that temporary code from /ww.admin/pages/pages.js.

Because this is a very useful feature, which we will use throughout the admin area whenever a date is needed, we will add a global JavaScript file which will run on all pages.

Edit /ww.admin/header.php and add the following highlighted line:

And then we'll create the /ww.admin/j/ directory and a file named /ww.admin/j/admin.js:

Code View: Scroll / Show All

function convert_date_to_human_readable(){

var $this=$(this);

var id='date-input-'+Math.random().toString()

.replace(/\./,'');

var dparts=$this.val().split(/-/);

$this

.datepicker({

dateFormat:'yy-mm-dd',

modal:true,

altField:'#'+id,

altFormat:'DD, d MM, yy',

onSelect:function(dateText,inst){

this.value=dateText;

}

});

var $wrapper=$this.wrap(

'

');

var $input=$('

value="'+date_m2h($this.val())+'" />');
$input.insertAfter($this);
$this.css({
'position':'absolute',
'opacity':0
});
$this
.datepicker(
'setDate', new Date(dparts[0],dparts[1]-1,dparts[2])
);
}
$(function(){
$('input.date-human').each(convert_date_to_human_readable);
});

This takes the computer-readable date input and creates a copy of it, but in a human-readable format.

The original date input box is then made invisible and laid across the new one. When it is clicked, the date is updated on both of them, but only the human-readable one is shown.

---

 Apart from CKeditor, the only other very popular RTE is TinyMCE. There are many other editors, but when you read about them, they are usually compared against CKeditor or TinyMCE.

--- 

One problem with home-grown templating engines is that they do not always have the robustness and speed of the more established engines.

Smarty speeds up its parsing by compiling the template into a PHP script, and then caching that script in a directory set aside for that purpose.

File layout of a theme

We've discussed how a templating engine works. Now let's look at a more concrete example.

  1. Create a directory /ww.skins in the CMS webroot.

  2. Within that directory, each theme has its own directory. We will create a very simple theme called "basic".

  3. Create a directory /ww.skins/basic, and in that, create the following directories:

    /ww.skins/basic/h This will hold the HTML template files.
    /ww.skins/basic/c This will hold any CSS files.
    /ww.skins/basic/i This will hold images.

Usually, that will be enough for any theme. The only necessary directory there is /h. The others are simply to keep things neat.

If I wanted to add JavaScript specific to that theme, then I would add it to a /ww.skins/basic/j directory. You can see how it all works.

In this basic theme, we will have two templates. One with a menu across the top (horizontal), and one with a menu on the left-hand side (vertical). We will then assign these templates to different pages in the admin area.

In the admin area, the templates will be displayed in alphabetical order.

If there is one template that you prefer to be the default one used by new pages, then the template should be named _default.html. After sorting alphabetically, the underscore causes this file to be top of the list, versus other filenames which begin with letters.

.html is used as the extension for the template so that the designer can easily view the file in a browser to check that it looks okay.

Let's create a default template then, with a menu on the left-hand side. Create this file as /ww.skins/basic/h/_default.html:


{{$METADATA} }


{{MENU
direction="horizontal"}}{{$PAGECONTENT}}


The reason that {{ is used instead of {, is that it if the designer used the brace character ({) for anything in the HTML, or the admin used it in the page content, then it would very likely cause the templating engine to crash — it would become confused because it could not tell whether you meant to just display the character, or use it as part of a code.

By doubling the braces {{ ... }}, we reduce the chance of this happening immensely. Doubled braces very rarely (I've never seen it at all) come up in normal page text.

The reason we use braces at all, and not something more obviously programmatic such as "", is that it is readable. It is easier to read "insert {{$pagename}} here" than to read "insert here".

I've introduced two variables and a function in the template:

{{$METADATA}}

This variable is an automatically generated string consisting of

child elements such as

Setting up Smarty

Okay — we have a simple template. Let's display it on the front-end.

To do this, we first edit /ww.incs/basics.php to have it figure out where the theme is. Add this code to the end of the file:

// { theme variables
if(isset($DBVARS['theme_dir']))
define('THEME_DIR',$DBVARS['theme_dir']);
else define('THEME_DIR',SCRIPTBASE.'ww.skins');
if(isset($DBVARS['theme']) && $DBVARS['theme'])
define('THEME',$DBVARS['theme']);
else{
$dir=new DirectoryIterator(THEME_DIR);
$DBVARS['theme']='.default';
foreach($dir as $file){
if($file->isDot())continue;
$DBVARS['theme']=$file->getFileName();
break;
}
define('THEME',$DBVARS['theme']);
}
// }

In this, we set two constants:

THEME_DIR

This is the directory which holds the themes repository. Note that we leave the option open for it to be located somewhere other than /ww.skins if we want to move it.

THEME

The name of the selected theme. This is the name of the directory which holds the theme files.

The $DBVARS array, from /.private/config.php, was originally intended to only hold information on database access, but as I added to the CMS, I found this was the simplest place to put information which we need to load in every page of the website.

Instead of creating a second array, for non-database stuff, it made sense to have one single array of site-wide configuration options. Logically, it should be renamed to something like $SITE_OPTIONS, but it doesn't really matter. I only use it directly in one or two places. Everywhere else, it's the resulting defined constants that are used.

After setting up THEME_DIR, defaulting to /ww.skins if we don't explicitly set it to something else, we then set up THEME.

If no $DBVARS['theme'] variable has been explicitly set, then THEME is set to the first directory found in THEME_DIR. In our example case, that will be the /ww.skins/basic directory.

Now we need to install Smarty.

To do this, go to http://www.smarty.net/download.php and download it. I am using version 2.6.26.

Unzip it in your /ww.incs directory, so there is then a /ww.incs/Smarty-2.6.26 directory.

We do not need to use Smarty absolutely everywhere. For example, we don't use it in the admin area, as there is no real need to do templating there.

For this reason, we don't put the Smarty setup code in /ww.incs/basics.php.

Open up /ww.incs/common.php, and add this to the end of it:

require_once SCRIPTBASE
. 'ww.incs/Smarty-2.6.26/libs/Smarty.class.php';
function smarty_setup($cdir){
$smarty = new Smarty;
if(!file_exists(SCRIPTBASE.'ww.cache/'.$cdir)){
if(!mkdir(SCRIPTBASE.'ww.cache/'.$cdir)){
die(SCRIPTBASE.'ww.cache/'.$cdir.' not created.
please make sure that '.USERBASE.'ww.cache is
writable by the web-server');
}
}
$smarty->compile_dir=SCRIPTBASE.'ww.cache/'.$cdir;
$smarty->left_delimiter = '{{';
$smarty->right_delimiter = '}}';
$smarty->register_function('MENU', 'menu_show_fg');
return $smarty;
}

As we'll see shortly, Smarty will not only be used in the theme's templates. It can be used in other places as well. To reduce repetition, we create a smarty_setup() function where common initializations are placed, and common functions are set up.

First, we make sure that the compile directory exists. If not, we create it (or die() trying).

We change the delimiters next to {{ and }}.

Also note the MENU function (you'll remember from the template code) is registered here. If Smarty encounters a MENU call in a template, it will call the menu_show_fg() function, which we'll define later in this chapter.

We do not define $METADATA or $PAGECONTENT here because they are explicitly tied to the page template.

Remove the last line (the echo $PAGEDATA->body; line) from /index.php.

We discussed how pages can have different "types". The $PAGECONTENT variable may need to be set up in different ways depending on the type, so we add a switch to the index.php to generate it:

// { set up pagecontent
switch($PAGEDATA->type){
case '0': // { normal page
$pagecontent=$PAGEDATA->body;
break;
// }
// other cases will be handled here later
}
// }

That gets the page body and sets $pagecontent with it (we'll add it to Smarty shortly).

Next, we need to define the $METADATA variable. For that, we'll add the following code to the same file (/index.php):

Code View: Scroll / Show All

// { set up metadata

// { page title

$title=($PAGEDATA->title!='')?

$PAGEDATA->title:

str_replace('www.','',$_SERVER['HTTP_HOST']).' > '

.$PAGEDATA->name;

$metadata='';

// }

// { show stylesheet and javascript links

$metadata.=' '

.' ' ;

// }

// { meta tags

$metadata.='

content="text/html; charset=UTF-8" />';
if($PAGEDATA->keywords)
$metadata.='';
if($PAGEDATA->description)$metadata.='<meta
http-equiv="description"
content="'.htmlspecialchars($PAGEDATA->description).'"
/>';
// }
// }

If a page title was not provided, then the title is set up as the server's hostname plus the page name.

We include the jQuery and jQuery-UI libraries on every page.

The Content-Type metadata is included because even if we send it as a header, sometimes someone may save a web page to their hard drive. When a page is loaded from a hard drive without using a server, there is no Content-Type header sent so the file itself needs to contain the hint.

Finally, we add keywords and descriptions if they are needed.

Note that we added jQuery-UI, but did not choose one of the jQuery-UI themes. We'll talk about that later in this chapter, when building the page menu.

Next, we need to choose which template to show. Remember that we discussed how site designs may have multiple templates, and each page needs to select one or another.

We haven't yet added the admin part for choosing a template, so what we'll do is, similar to the THEME setup, we will simply look in the theme directory and choose the first template we find (in alphabetical order, so _default.html would naturally be first).

Edit index.php and add this code:

Code View: Scroll / Show All

// { set up template
if(file_exists(THEME_DIR.'/'.THEME.'/h/'
.$PAGEDATA->template.'.html')){
$template=THEME_DIR.'/'.THEME.'/h/'
.$PAGEDATA->template.'.html';
}
else if(file_exists(THEME_DIR.'/'.THEME.'/h/_default.html')){
$template=THEME_DIR.'/'.THEME.'/h/_default.html';
}
else{
$d=array();
$dir=new DirectoryIterator(THEME_DIR.'/'.THEME.'/h/');
foreach($dir as $f){
if($f->isDot())continue;
$n=$f->getFilename();
if(preg_match('/^inc\./',$n))continue;
if(preg_match('/\.html$/',$n))
$d[]=preg_replace('/\.html$/','',$n);
}
asort($d);
$template=THEME_DIR.'/'.THEME.'/h/'.$d[0].'.html';
}
if($template=='')die('no template created.
please create a template first');
// }

So, the order here is:

  1. Use the database-defined template if it is defined and exists.

  2. Use _default.html if it exists.

  3. Use whatever is alphabetically first in the directory.

  4. die()!

The reason we check for _default.html explicitly is that it saves time. We have set the convention so when creating a theme the designer should name the default template _default.html, so it is a waste of resources to search and sort when it can simply be set.

Note that we are ignoring any templates which begin with "inc.". Smarty can include files external to the template, so some people like to save the HTML for common headers and footers in external files, then include them in the template. If we simply add another convention that all included files must start with "inc." (for example, inc.footer.html), then using this code, we will only ever select a full template, and not accidentally use a partial file.

For full instructions on what Smarty can do, you should refer to the online documentation at http://www.smarty.net/.

Finally, we set up Smarty and tell it to render the template.

Add this to the end of the same file:

$smarty=smarty_setup('pages');
$smarty->template_dir=THEME_DIR.'/'.THEME.'/h/';
// { some straight replaces
$smarty->assign('PAGECONTENT',$pagecontent);
$smarty->assign('PAGEDATA',$PAGEDATA);
$smarty->assign('METADATA',$metadata);
// }
// { display the document
header('Content-type: text/html; Charset=utf-8');
$smarty->display($template);
// }

This section first sets up Smarty, telling it to use the /ww.cache/pages directory for caching compiled versions of the template.

Then the $pagecontent and $metadata variables are assigned to it.

We also assign the $PAGEDATA object to it, which lets us expose the page object to Smarty, in case the designer wants to use some aspect of it directly in the design. For example, the page name can be displayed with {{$PAGEDATA->name|escape}}, or the last edited date can be shown with {{$PAGEDATA->edate|date_format}}.

Before viewing this in a browser, edit the /ww.skins/basics/_default.html file, and change the double braces around the MENU call to single braces. We haven't yet defined that function, so we don't want Smarty to fail when it encounters it.

When viewed in a browser, we now have this screenshot:

It is very similar to the one from Chapter 1, CMS Core Design, except that we now have the page title set correctly.

Viewing the source, we see that the template has correctly been wrapped around the page content:

Okay — we can now see that the templating engine works for simple variable substitution. Now let's add in functions, and get the menu working.

Before going onto the next section, edit the template again and fix the braces so they're double again.

Front-end navigation menu

The easiest way to create a navigation menu is simply to list all the pages on the site. However, that does not give a contextual feel for where everything is in relation to everything else.

In the admin area, we created a hierarchical <ul> list. This is probably the easiest menu which gives a good feel. And, using jQuery, we can provide that <ul> list in all cases and transform it to whatever we want.

Let's start by creating the templating engine's MENU function with a <ul> tree, and we'll expand on that afterwards.

We've already registered MENU to run the function show_menu_fg(), so let's create that function.

We will add it to /ww.incs/common.php, where most page-specific functions go:

Code View: Scroll / Show All

function menu_show_fg($opts){
$c='';
$options=array(
'direction' => 0, // 0: horizontal, 1: vertical
'parent' => 0, // top-level
'background'=> '', // sub-menu background colour
'columns' => 1, // for wide drop-down sub-menus
'opacity' => 0 // opacity of the sub-menu
);
foreach($opts as $k=>$v){
if(isset($options[$k]))$options[$k]=$v;
}
if(!is_numeric($options['parent'])){
$r=Page::getInstanceByName($options['parent']);
if($r)$options['parent']=$r->id;
}
if(is_numeric($options['direction'])){
if($options['direction']=='0')
$options['direction']='horizontal';
else $options['direction']='vertical';
}
$menuid=$GLOBALS['fg_menus']++;
$c.='<div class="menu-fg menu-fg-'.$options['direction']
.'" id="menu-fg-'.$menuid.'">'
.menu_build_fg($options['parent'],0,$options)
.'</div>';
return $c;
}
$fg_menus=0;

menu_show_fg() is called with an array of options as its only parameter. The first few lines of the function override any default values with values that were specified in the array (inspired by how jQuery plugins handle options).

Next, we set up some variables, such as getting details about the menu's parent page if there is one, and convert the direction to use words instead of numbers if a number was given.

Then, we generate an ID for the menu, to distinguish it from any others that might be on the page. This is stored in a global variable. In a more structured system, this might be stored in a static variable in a class (such as how Page instances are cached in the /ww.php_classes/Page.php file), but the emphasis here is on speed, and it's quicker to access a variable directly than to find a class and then read the variable.

Finally, we build a wrapper, fill it with the menu's <ul> tree, and return the wrapper.

The <ul> tree itself is built using a second function, menu_build_fg(), which we'll add in a moment.

Before doing that, we need to add a new method to the Page object. We will be showing links to pages, and need to provide a function for creating the right address. Edit /ww.php_classes/Page.php and add these methods to the Page class:

function getRelativeURL(){
if(isset($this->relativeURL))return $this->relativeURL;
$this->relativeURL='';
if($this->parent){
$p=Page::getInstance($this->parent);
if($p)$this->relativeURL.=$p->getRelativeURL();
}
$this->relativeURL.='/'.$this->getURLSafeName();
return $this->relativeURL;
}
function getURLSafeName(){
if(isset($this->getURLSafeName))
return $this->getURLSafeName;
$r=$this->urlname;
$r=preg_replace('/[^a-zA-Z0-9,-]/','-',$r);
$this->getURLSafeName=$r;
return $r;
}

The getRelativeUrl() method ensures that a page's link includes its parents and so on. For example, if a page's name is page2 and it is contained under the parent page page1, then the returned string is /page1/page2, which can be used in <a> elements in the HTML.

The getURLSafeName() ensures that if the admin used any potentially harmful characters such as !£$%^&*? in the page name, then they are converted to - in the page name. When used in a MySQL query, the hyphen character - acts as a wildcard. So for example, if there is a page name "who are tom & jerry?", then the returned string is who-are-tom---jerry-. This method is commonly used in blog software where its desired that the page name is used in the URL.

Combined, these methods allow the admin to provide "SEO-friendly" page addresses without needing them to remember what characters are allowed or not. Of course, it means that there may be clashes if someone creates one page called "test?" and another called "test!", but those are rare and it is easy for the admin to spot the problem.

Back to the menu — let's add the menu_build_fg() function to /ww.incs/common.php. This will be a large function, so I'll explain it a bit at a time:

Code View: Scroll / Show All

function menu_build_fg($parentid,$depth,$options){
$PARENTDATA=Page::getInstance($parentid);
// { menu order
$order='ord,name';
if(isset($PARENTDATA->vars->order_of_sub_pages)){
switch($PARENTDATA->vars->order_of_sub_pages){
case 1: // { alphabetical
$order='name';
if($PARENTDATA->vars->order_of_sub_pages_dir)
$order.=' desc';
break;
// }
case 2: // { associated_date
$order='associated_date';
if($PARENTDATA->vars->order_of_sub_pages_dir)
$order.=' desc';
$order.=',name';
break;
// }
default: // { by admin order
$order='ord';
if($PARENTDATA->vars->order_of_sub_pages_dir)
$order.=' desc';
$order.=',name';
// }
}
}
// }
$rs=dbAll("select id,name,type from pages where parent='"
.$parentid."' and !(special&2) order by $order");
if($rs===false || !count($rs))return '';

This first section gets the list of pages in this level of the menu from the database.

First, we get data about the parent page.

Next we figure out what sorting order the admin wanted that parent page's sub-pages to be displayed in, and we build up an SQL statement based on that.

Note the and !(special&2) part of the SQL statement. As explained in the previous chapter, we're using the special field as a bitfield. The & here is a Boolean AND function and returns true if the 2 bit is set (the 2 bit corresponds to "Does not appear in navigation"). So what this section means is "and not hidden".

If no pages are found, then an empty string is returned.

Now add this part of the function to the file:

$items=array();
foreach($rs as $r){
$item='<li>';
$page=Page::getInstance($r['id']);
$item.='<a href="'.$page->getRelativeUrl().'">'
.htmlspecialchars($page->name).'</a>';
$item.=menu_build_fg($r['id'],$depth+1,$options);
$item.='</li>';
$items[]=$item;
}
$options['columns']=(int)$options['columns'];
// return top-level menu
if(!$depth)return '<ul>'.join('',$items).'</ul>';

What happens here is that we take the result set we got from the database in the previous section, and we build a list of links out of them using the getRelativeURL() method to generate safe URLs, and then display the admin-defined name using htmlspecialchars().

Before each <li> is closed, we then recursively check menu_build_fg() with the current link as the new parent (the highlighted line). If there are no results, then the returned string will be blank. Otherwise it will be a sub-<ul> which will be inserted here.

If we are at the top level of the menu, then this generated list is immediately returned, wrapped in <ul>...</ul> tags.

The next section of code is triggered only if the call was to a sub-menu where $depth is 1 or more, for example from the call in the highlighted line in the last code section:

$s='';
if($options['background'])$s.='background:'
.$options['background'].';';
if($options['opacity'])$s.='opacity:'
.$options['opacity'].';';
if($s){
$s=' style="'.$s.'"';
}
// return 1-column sub-menu
if($options['columns']<2)return '<ul'.$s.'>'
.join('',$items).'</ul>';

This section checks to see if the options array had background or opacity rules for sub-menus, and applies them.

This is useful in the case that you are switching themes in the admin area, and the theme you switch to hasn't written CSS rules about sub-menus. It is very hard to think of every case that can occur, so designers sometimes don't cover all cases. As an example of this, imagine you have just created a new plugin for the CMS, and it looks good in a new theme designed specifically for it. The admin however, might prefer the general look of an older theme and selects it in the admin area (we'll get to that in this chapter). Unfortunately, that older theme does not have CSS rules to handle the new code.

In these cases, we need to provide workarounds so the code looks okay no matter the theme. In a later chapter, we'll look at how the menu options can be adjusted from the admin area, so that an admin can choose the sub-menu background color and opacity to fit any design they choose (in case the theme has not covered the case already).

The final line of the example returns the sub-menu wrapped in a <ul> element in the case that only one column is needed (the most common sub-menu type, and the default).

Now, let's add some code for multi-column sub-menus:

// return multi-column submenu
$items_count=count($items);
$items_per_column=ceil($items_count/$options['columns']);
$c='<table'.$s.'><tr><td><ul>';
for($i=1;$i<$items_count+1;++$i){
$c.=$items[$i-1];
if($i!=$items_count && !($i%$items_per_column))
$c.='</ul></td><td><ul>';
}
$c.='</ul></td></tr></table>';
return $c;
}

In a number of places throughout the book, I've used HTML tables to display various layouts. While modern designers prefer to avoid the use of tables for layout, sometimes it is much easier to use a table for multi-columned layouts, then to try to find a working cross-browser CSS alternative. Sometimes the working alternative is too complex to be maintainable.

Another reason is that if we were to use a CSS alternative, we would be pushing CMS-specific CSS into the theme, which may conflict with the theme's own CSS. This should be avoided whenever possible.

In this case, we return the sub-menu broken into multiple columns. Most sites will not need this, but in sites that have a huge number of entries in a sub-menu and the sub-menu stretches longer than the height of the window, it's sometimes easier to use multiple columns to fit them all in the window than to get the administrator to break the sub-menu down into further sub-categories.

We can now see this code in action. Load up your home page in the browser, and it should look something like the next screenshot:

In my own database, I have two pages under /Home, but one of them is marked as hidden.

So, this shows how to create the navigation tree.

In the next chapter, we will improve on this menu using jQuery, and will then write a theme management system.