Simple Custom Router Documentation

The Simple Custom Router is a Joomla! extension to associate paths with internal Joomla! queries. For example, you can associate the path hello-world with the query option=com_content&view=article&id=42, so when a user types the URL http://yourServer/BaseJoomlaUrl/hello-world the article with id 42 is loaded, and when the internal Joomla! URL index.php?option=com_content&view=article&id=42 is translated to a humanly readable URL, http://yourServer/BaseJoomlaUrl/hello-world will be generated.

Paths and internal Joomla! queries can be parametrized using the PHP regular expressions syntax. For example, you can associate the path article-(\d+)/view with the query option=com_content&view=article&id={1}, so when a user types the URL http://yourServer/BaseJoomlaUrl/article-4/view the article with id 4 is loaded, but when a user types the URL http://yourServer/BaseJoomlaUrl/article-23/view the article with id 23 is loaded. Similarly, when the internal Joomla! URL index.php?option=com_content&view=article&id=4 is translated to a humanly readable URL, http://yourServer/BaseJoomlaUrl/article-4/view will be generated, and when the internal Joomla! URL index.php?option=com_content&view=article&id=23 is translated to a humanly readable URL, http://yourServer/BaseJoomlaUrl/article-23/view will be generated.

The paths and internal Joomla! queries can be added, edited and removed in the Joomla! backend. The administrator component of the Simple Custom Router also offers a way to check which query or path will be generated for some path or query. Finally, a cache can be enabled in the plugin configuration to speed up the routing.

The Simple Custom Router is licensed under the GNU Affero General Public License version 3, or (at your option) any later version.

Download and installation

The Simple Custom Router is composed of a plugin and an administrator component. They are both provided in a single general package:

If you have an older version installed you can see the list of changes between versions to know the differences between your version and the latest one.

The general package can be installed like any other Joomla! extension. Download it to your system and then upload it in the Extension Manager, or just copy the link to the general package directly in the Extension Manager, or (for Joomla! 3.2 and later) use the Install from Web feature. Installing the general package will install the plugin and the component; to uninstall them, just uninstall the general package and the plugin and the component will be automaticaly uninstalled.

Remember that, like any other plugin, the Simple Custom Router plugin will be disabled after the installation. You must enable it explicitly from the Extension Manager once installed. You must also enable (and configure as needed; see the tooltip on the setting for further information) URL Rewriting in the Site tab of the Global Configuration of Joomla!.

Finally, take into account that the Simple Custom Router is licensed as GNU AGPL, not GNU GPL. It means that, if you modify the code of the extension, you must provide a way for your users to download the modified code of the extension.

Details

The Simple Custom Router associates paths and internal Joomla! queries through routes. A route contains a path and an internal Joomla! query; the path is what the user sees in the address bar (but for the base Joomla! URL and extra query fields), and the internal Joomla! query is how Joomla identifies what view to show or what action to do. When the path is parsed (the browser loads it), the associated query is used. Reciprocally, when the query is built (it is translated to be shown as a link in the page), the associated path is used.

For example, you can associate the path hello-world (note that http://yourServer/BaseJoomlaUrl is not really part of the path) with the query option=com_content&view=article&id=42 (note that the index.php? is not really part of the query), so when the path hello-world is parsed, the query option=com_content&view=article&id=42 will be used, and when the query option=com_content&view=article&id=42 is built, the path hello-world will be generated.

When a path is parsed, an exact match is looked for against the paths of the routes. For example, if there is a route with the path search/articles and the query option=com_sherlock&view=search&area=content, when the path search/articles is parsed, the query option=com_sherlock&view=search&area=content will be used. However, if the path search is parsed it will not match with the path of the route. In this case, if search does not match with the path of any other route it is handled by the default Joomla! router.

Note, however, that extra query fields may appear when a path is parsed, and that they are not part of the path. For example, when the path search/articles?searchword=foo is parsed it does match the route with the path search/articles, so the query option=com_sherlock&view=search&area=content&searchword=foo will be used (note that the extra query fields are just added to the internal Joomla! query of the route).

When an internal Joomla! query is built things are a little bit different. An exact match is looked for against all the key=value pairs in the queries of the routes. For example, when the queries option=com_sherlock&view=search&area=content or view=search&option=com_sherlock&area=content are built (note that the order of the fields does not matter), the path search/articles will be generated. However, if the query option=com_sherlock&view=search&area=tags is built it will not match with the query of the route (as there is a field that does not match). In this case, if option=com_sherlock&view=search&area=tags does not match with the query of any other route it is handled by the default Joomla! router. The query option=com_sherlock&view=search does not match either with the query of the route, as in this case the query of the route has more fields that were not matched.

Note, however, that extra query fields would match the query of the route. For example, when the query option=com_sherlock&view=search&area=content&searchword=foo is built it does match the route with the query option=com_sherlock&view=search&area=content (as all the fields of the query of the route are matched), so the path search/articles?searchword=foo will be used (note that the extra query fields are just added to the path).

In any case, only keys with a single value can be used in a query; unfortunately, multivalued keys are not supported yet (they may be supported in a future release if a get some time to add that feature, or if someone hires me to work on it :P ). That is, the query option=com_sherlock&view=search&area[]=content&area[]=tags is not valid in a route (multivalued keys are those that end in [], or in [0], [1], [2]...). On the other hand, a normal key with several values assigned to it can be used (for example, option=com_sherlock&view=search&area=content&area=tags), although it does not make much sense, as only one of all the given values for the key will be taken into account by Joomla!.

If you do not know the internal Joomla! query used to show certain page the easiest way to find it is, in the backend, to disable both Search Engine Friendly URLs and URL Rewriting in the Site tab of the Global Configuration and then, in the frontend, look for a link that shows the desired page (as, due to the changes in the backend, all the links in the frontend would use now internal Joomla! queries). Of course, both options should be enabled again once the query is found.

Parametrized routes

A route can be parametrized using PHP regular expressions syntax. For example, a route may contain the path article-(\d+)/view and the query option=com_content&view=article&id={1}. When the path article-4/view is parsed, the query option=com_content&view=article&id=4 will be used. If the query option=com_content&view=article&id=23 is built, the path article-23/view will be generated.

Any number of parameters and any regular expression can be used; just wrap the regular expression with parenthesis in the path, and wrap the parameter place with keys in the query. For example, a route may contain the path (archive|article|category)-(\d+)/view and the query option=com_content&view={1}&id={2}. When the path article-15/view is parsed, the query option=com_content&view=article&id=15 will be used. If the query option=com_content&view=category&id=16 is built, the path category-16/view will be generated. The parameters do not need to appear in the query in the same order as in the path; if you prefer you can have a route with path (archive|article|category)-(\d+)/view and query option=com_content&id={2}&view={1}.

Note, however, that the same number of parameters must be used in the path and the query. You can not omit a parameter. There is no way either to repeat a parameter. That is, you can have two parameters that match against integer numbers, but you can not ensure that both parameters have the same value.

Also note that only the parameters will be treated as a regular expression; for example, a route with the path best.articles-(\d+) will match to best.articles-8, but not to best-articles-8 (that is, the dot is just a regular dot).

Routes and menu items

Routes can be associated to menu items, so when the path of the route is parsed, the associate menu item becomes the active menu item. This is useful mostly to assign a specific template to the route.

The menu item associated to the route will always take precedence. If the parsed path contains an extra query with the field Itemid=someMenuItemId it will be ignored and the menu item associated to the route will be used. If there is no menu item associated to the route no menu item will be set as the active one, neither the one specified in the extra query. Due to this, when the query of a route is built, if it contains a field Itemid=someMenuItemId it will just be removed, as when the generated path is parsed the menu item from the route will be used anyway.

Note that the menu item associated to a route should be a dummy or placeholder menu item and it should not appear in a menu module. The reason is that, although when the path of the route associated to the menu item is parsed the menu item becomes the active one, the menu item itself knows nothing about the route or its path. The menu item has its own link, which has nothing to do with the route, so clicking on the menu item link in a menu module will not load the path of the route.

That is, to associate a route with a menu item so the route has a specific template, create a special menu that will not appear in any menu module, and add a menu item on it. Then, set the desired template for that menu item. The best type for this placeholder menu item is External URL with an empty link.

However, if the placeholder menu item must be part of a menu shown in a menu module (for example, because you want a parent menu item to appear highlighted in the menu module when the menu item of the route is the active one), you may find useful a little modification I made over the standard Joomla! menu module: Menu With Item Selection. It behaves like the standard menu module, but you can select individually which menu items should appear in it, instead of basing it in the depth of the items and so on.

Conflicts between routes

It can happen that a path or query matches with two or more routes. Which route is used then?

In the case of paths, if it matches with a route with a parametrized path and with a route with an explicit path, the route with the explicit path is selected. That is, if there is a route with the path article-(\d+) and query option=com_content&view=article&id={1}, and a route with the path article-42 and query option=com_eastereggs&id=42, when the path article-42 is parsed the query option=com_eastereggs&id=42 will be used. But, if the path matches two routes that have the same path, or if the path matches two parametrized routes, the router has no way to decide which route is the best one. In this case, the first route found is used.

In the case of queries, the route with more fields is selected. Between two routes with the same number of fields, the route with less parameters is selected. That is, if there is a route with the path search and the query option=com_sherlock&view=search, a route with the path search-articles and the query option=com_sherlock&view=search&area=content, and a route with the path search/(\w+) and the query option=com_sherlock&view=search&area={1}, when the query option=com_sherlock&view=search&area=content is built the path search-articles will be used. But, if the query matches two routes with the same number of fields and the same number of parameters, the router has no way to decide which route is the best one. In this case, the first route found is used.

Conflict solving plugins

If you need proper conflict management for some routes you can do it writing a Joomla! system plugin. For example, suppose that you have a route with the path guest/view-article-(\d+) and the query option=com_content&view=article&id={1}, and another route with the path registered/view-article-(\d+) and the query option=com_content&view=article&id={1}. That is, you want a different path for the same article depending on whether the user is a guest or not. The paths are different, so there is no conflict between them; however, the query is the same in both routes, so if that query is built the first route will be selected always.

You need to write a Joomla! system plugin that is executed when the onAfterInitialise event is triggered. It will attach to the Joomla! router a build rule to solve the conflict when building the problematic query. For example:

class plgSystemYourPlugin extends JPlugin {

    function onAfterInitialise() {
        $app = JFactory::getApplication();
        if ($app->isAdmin()) {
            return;
        }

        $router = $app->getRouter();
        $router->attachBuildRule(array($this, 'build'));

        return true;
    }

    public function build(&$siteRouter, &$uri) {
        $path = $uri->getPath();
        $query = $uri->getQuery(true);

        //Conflict solving goes here
        if ($query['option'] == 'com_content' &&
            $query['view'] == 'article' &&
            isset($query['id'])) {

            //Set the proper path based on whether the user is a guest or not
            if (JFactory::getUser()->guest) {
                $path = 'guest/view-article-'.$query['id'];
            } else {
                $path = 'registered/view-article-'.$query['id'];
            }

            //Unset the matched query variables
            unset($query['option']);
            unset($query['view']);
            unset($query['id']);
        }

        //Conflicts between other routes would be solved here following the
        //schema above

        //Check if the conflict was solved and update the URI as needed
        if ($path != $uri->getPath() || $query != $uri->getQuery(true)) {
            //If the route was found, the Itemid is unset to mimick the Simple
            //Custom Router behaviour.
            unset($query['Itemid']);

            $uri->setPath($path);
            $uri->setQuery($query);
        }

        return $uri;
    }
}

Suppose now that the conflict happens between two paths. For example, you have a route with the path article-(\d+) and the query option=com_content&view=article&id={1}&detailLevel=guest, and a route with the path article-(\d+) and the query option=com_content&view=article&id={1}&detailLevel=registered. That is, you want a different query for the same path depending on whether the user is a guest or not. In this case, your system plugin would look like something like this:

class plgSystemYourPlugin extends JPlugin {

    function onAfterInitialise() {
        $app = JFactory::getApplication();
        if ($app->isAdmin()) {
            return;
        }

        $router = $app->getRouter();
        $router->attachParseRule(array($this, 'parse'));

        return true;
    }

    public function parse(&$siteRouter, &$uri) {
        $path = $uri->getPath();
        $path = str_replace(JURI::base() . '/', '', $path);
        $path = rtrim($path, '/');

        $newQuery = false;
        $itemId = null;

        //Conflict solving goes here
        if (preg_match('#article-(\d+)#', $path, $matches)) {
            if (JFactory::getUser()->guest) {
                $newQuery = 'option=com_content&view=article&id='.$matches[1].'&detailLevel=guest';
            } else {
                $newQuery = 'option=com_content&view=article&id='.$matches[1].'&detailLevel=registered';
                //If a menu item is associated with the route set its id here
                $itemId = 42;
            }
        }

        //Conflicts between other routes would be solved here following the
        //schema above

        //Check if the conflict was solved and update the URI as needed
        if ($newQuery) {
            $oldQuery = $uri->getQuery(false);
            if (!empty($oldQuery)) {
                $newQuery = $newQuery.'&'.$oldQuery;
            }

            //Remove Itemid from the query
            $newQuery = preg_replace('#Itemid=[^&]*&#', '', $newQuery);
            $newQuery = preg_replace('#&?Itemid=.*#', '', $newQuery);

            $uri->setPath('');
            $uri->setQuery($newQuery);

            JFactory::getApplication()->input->set('Itemid', $itemId);
        }

        return array();
    }
}

Dependency on URL rewriting

The Simple Custom Router needs the URL rewriting setting to be enabled to work. However, note that it can route only the URLs handled by Joomla!, not those handled directly by the web server.

When a URL is requested to the web server, the web server may hand it over to Joomla!, or it may just process the URL by itself, depending on whether the URL matches some pattern or not. Those patterns are defined in the .htaccess file when using Apache, or in the web.config file when using IIS.

For example, using the default files provided by Joomla! you could create a route with the query option=com_mygallerycomponent&view=image&id=42 and the path gallery/photos/sunset, but not with the path gallery/photos/sunset.png. The reason is that the default patterns hand extensionless URLs to Joomla!, but not URLs ending in .png. So, if a route ending in .png was added, when trying to load the URL a 404 error from the web server would appear, as the web server would try to handle the URL itself instead of handing it to Joomla!. The .htaccess or web.config files would need to be modified in this case to hand URLs ending in .png to Joomla!. Please refer to the files and the documentation of your web server for further information.

Backend

The routes can be added, edited and removed in the Joomla! backend using the Simple Custom Router component. It provides a route manager similar to other managers found in the Joomla! backend. For each route its path, query and associated menu item can be set.

Besides managing the routes, the Simple Custom Router component also offers a system to test which query will be generated for a path, and which path will be generated for a query. So you can use it to check, for example, if the regular expressions of your parameters behave as you expect. However, note that this system only shows the queries and paths generated by the Simple Custom Router itself. If there is a plugin to solve route conflicts it will not be taken into account, and neither the default Joomla! router will be.

Although parsing paths is easy, building queries is somewhat more complex, and thus takes more time. The Simple Custom Router plugin can be configured to use a cache to speed up its operations. The Simple Custom Router plugin configuration is set, like the configuration of any other plugin, using the Plug-in Manager in the Joomla! backend. There you can just follow the general Joomla! cache configuration, or override it and enable or disable the cache for the Simple Custom Router plugin, no matter what the status of the general Joomla! cache is.

Incompatibility with Language Filter plugin

Unfortunately, the Simple Custom Router does not work when the Language Filter plugin from Joomla! (used for multilingual sites) is enabled. What follows is a developer explanation of why it does not work. If you are not a developer or do not want to read that, you just need to know that the Language Filter plugin "eats" all the routes before they can be handled by the Simple Custom Router.

The problem comes from the initialisation of the Language Filter plugin, which happens before the initialisation of the Simple Custom Router plugin. The initialisation order of two plugins defines the order in which the events will be dispatched to them. So, if the Language Filter plugin is initialised before the Simple Custom Router plugin, when an event is triggered, the Language Filter plugin will be executed before the Simple Custom Router plugin.

Both plugins use the onAfterInitialise event to attach their rules to the main Joomla! router, so the first plugin to receive the event is the first plugin to attach its rules and, thus, it is the first to handle the parsing or building of routes. Therefore, the first plugin initialised (the Language Filter plugin) is the first plugin that gets called whenever a route has to be parsed or built. As the Language Filter plugin does not "understands" the routes defined in the Simple Custom Router the system does not work as expected.

The solution, therefore, would be to initialise the Simple Custom Router plugin before the Language Filter plugin. Unfortunately, there is no way to do that (at least, not without modifying the Joomla! code itself). When the system plugins are initialised in the JPluginHelper::importPlugin('system'); call made in the method initialiseApp($options) of the JApplicationCms class the order in which the initialisation happens is based on the ordering parameter of the plugins, which can be set in the configuration of each plugin in the Joomla! backend.

However, the Language Filter plugin, despite being a system plugin, is not initialised there like the rest of the system plugins. Instead, it is explicitly initialised in the JPluginHelper::importPlugin('system', 'languagefilter'); call made in the method initialiseApp($options) of the JApplicationSite class. The JApplicationSite class derives from the JApplicationCms class, and the initialiseApp($options) method of the parent class is called from JApplicationSite after the Language Filter plugin has been initialised. Therefore, the Language Filter plugin is always initialised before the Simple Custom Router, no matter the ordering set in the configuration.

Unit and system tests

Note: if you do not intend to modify the code of the extension you can safely skip this section ;)

There are unit tests available for the plugin, and system tests available for the package (to ensure that the component and plugin work together). Both unit and system tests use Joomla! test infrastructure, so it has to be available in your Joomla! installation (if you are using the git version you should have it already; if you are using a packaged release you should download the test infrastructure for your release from git) and properly configured (tests/system/servers/config-def.php in Joomla! 3.x; tests/system/servers/config-def.php and tests/unit/config.php in Joomla! 1.7.x/2.5.x). PHPUnit >= 3.4 is needed for the Simple Custom Router tests, although the Joomla! test infrastructure may need even a higher version (and, in fact, it does in Joomla! 3.4). For further details about Joomla! tests please see Joomla! documentation.

Unit tests

The unit tests for the plugin are included in the package of the plugin, which is included in the general package of the extension. That is, pkg_simplecustomrouter.zip contains another zip file, simplecustomrouter.zip, which is the package of the plugin. The unit tests are inside the package of the plugin, in the directory unit-tests.

Although the unit tests are included in the package, they are not installed when the package is installed. So, to install them, copy the contents from the directory unit-tests to the directory tests/unit/suites/plugins/system/simplecustomrouter/ of your Joomla! 3.x installation, or to the directory tests/unit/suite/plugins/system/simplecustomrouter/ of your Joomla! 1.7.x/2.5.x installation. You may need to create the directories plugins and system, and you will need to create the directory simplecustomrouter.

The file simplecustomrouter.csv is used before each test to initialise the database with the necessary data. Since Joomla! 3.x, an in-memory database is used for the unit tests that depend on a database. The structure of the tables is initialised automatically before the tests are run, so there is nothing to configure or prepare manually before running the tests.

Things are a little more complicated in Joomla! 1.7.x/2.5.x, though. In those versions a real database is used, and the table itself and its structure must be created before the tests are run. create-test-tables.sql is provided for convenience; replace #_ with the proper prefix name and execute the SQL queries in the database. However, note that Joomla! unit tests do not restore the database to its previous state once all the tests end, so ensure that a test only database is set in your configuration. Also note that the file simplecustomrouter.csv must be in tests/unit/suite/plugins/system/simplecustomrouter/ along with the unit test files, and not in tests/unit/stubs/ like other database initialisation files do.

There are two unit test classes: plgSystemSimpleCustomRouterTest and SimpleCustomRouterTest. The first checks that the plugin itself attachs the rules to the Joomla! router and honours the configuration of the plugin, and the second checks that the parse and build rules behave as they should.

System tests

The system tests are included in the general package of the extension, that is, pkg_simplecustomrouter.zip, in the directory system-tests.

Although the system tests are included in the package, they are not installed when the package is installed. So, to install them, copy the file SimpleCustomRouterTest.php from the directory system-tests to the directory tests/system/suite/simplecustomrouter/ of your Joomla! 1.7.x/2.5.x/3.x installation (you will need to create the directory simplecustomrouter and, in Joomla! 3.4 and later, also the directory suite). Also, if using Joomla! 3.4 or later, you will need to copy too the file SeleniumJoomlaTestCase.php to the directory tests/system/ (as it is no longer used in Joomla! CMS tests since 3.4).

In the directory system-tests there is another php file, backupRestore.php. This file must be copied to the directory tests/system/. The system tests are performed on a real database containing a real Joomla! installation, and thanks to that file before each simplecustomrouter system test is run the database and Joomla configuration are backed up, and after each simplecustomrouter system test is run the database and configuration are restored. Note that Joomla! system tests do not back up and restore the database nor the configuration either; it is just a feature of the simplecustomrouter system tests. It has a drawback, though: the commands mysqldump, mysql and cp (POSIX copy) have to be executable from PHP using shell_exec.

There is only one system test class, SimpleCustomRouterTest.php, which checks that routes added, edited and removed in the backend are used in the frontend (with and without cache), that the routes can be tested from the backend, and that the routes are sorted and searched as expected in the backend.

Finally, note that the system tests assume that URL Rewriting is enabled and properly configured, that when Joomla! shows a "Not found" page its title starts by "Error: 404" in Joomla! 3.x or by "404" in Joomla! 1.7.x/2.5.x (the default Joomla! behaviour), that the simplecustomrouter plugin is enabled (before Simple Custom Router 0.2; in 0.2 release and later the system tests enable and disable the plugin as needed for the tests), and that the simplecustomrouter plugin is not checked in (its configuration is not being edited by other administrator when the tests are started).

About

The Simple Custom Router was developed by me, Daniel Calviño Sánchez.

I developed it to cover my own needs, and published it in the hope that it will be useful to someone :)