Concrete5

Auto-generate page locations based on date in Concrete5.7.

Updated October 12, 2015: Added some details about how the PageHandler() class is included in the package.

99% of those reading this blog won't notice, but posts here display their URLs in a very traditional blog format.

/year/month/post-url-slug

This format was popularized by blogging software like Wordpress and Movable Type. This is pretty standard in Wordpress, but if you've done any Concrete5 development you'll realize that this is not the norm. In fact, in my previous site, all my articles lived under the /posts/ URL, with no indication of when they were written.

/posts/post-url-slug

This is typically how we do things in Concrete5 – your URL matches the sitemap structure of your site, and your blog consists of a page of the "Blog" type, with pages of the "Blog Entry" page type beneath it. This is how our sample content blog in the Elemental theme works.

I'm going to let you in on a dirty little secret: I've never liked this. I've never been satisfied with this. Of course, this is odd – since I'm the one who wrote this, and it's been this way in Concrete since before we were open source, and before this software was even called Concrete. So why does it work this way? Simply put, I think for 90% of the CMS needs, the way we do things by default is best. You have a URL slug, and pages build their full URLs based on the hierarchy of these slugs. For full websites this makes the most sense.

But for blogs? I like having the year and the month in there. It feels like the site is more complex. You can chop off the slug and filter by month or by year. It gives greater context to a piece that you're reading. So with that in mind, I wanted to make that work for my website.

Sitemap and Page Types

First we setup a "Posts" page in our Sitemap. This is where our entire blog is going to live. This just a regular page – it doesn't need to be much of anything. Next, we setup our "Post" page type to publish only beneath the "Posts" page.

If were to start blogging now, we'd get a sitemap that looked like this

+ Posts
  - Post 1
  - Post 2
  - Post 3

with URLs that looked like this:

/posts/post-1
/posts/post-2
/posts/post-3

Custom Event Handlers

That's why we add some custom code.

$handler = new PageHandler();
\Events::addListener('on_page_type_publish', function($event) use ($handler) {
    $page = $event->getPageObject();
    if ($page->getPageTypeHandle() == 'post') {
        $handler->placePost($page);
    }
});
\Events::addListener('on_page_version_approve', function($event) use ($handler) {
    $page = $event->getPageObject();
    if ($page->getPageTypeHandle() == 'post' && $page->isPageDraft()) {
        $handler->placePost($page);
    }
});
\Events::addListener('on_page_move', function($event) use ($handler) {
    $page = $event->getPageObject();
    if ($page->getPageTypeHandle() == 'post') {
        $parent = $event->getOldParentPageObject();
        $handler->cleanPostParent($parent);
    }
});

This code lives in a custom package's on_start() method, so these event handlers are registered and always listening. Basically what we're doing is adding some custom handlers for any type a page of the Post type is published, approved or moved. placePost() and cleanPostParent() are functions inside a PageHandler class, which we include at the top of of our controller script:

use ElectricState\AndrewEmbler\Page\Handler as PageHandler;

This script is located within a custom package I have created for my site. It is found at packages/ae2014/src/ElectricState/AndrewEmbler/Page/Handler.php. How concrete5 know where to load \ElectricState\AndrewEmbler\Page\Handler? I've set up a custom autoloader within my package controller:

protected $pkgAutoloaderRegistries = array(
    'src/ElectricState/AndrewEmbler' => '\ElectricState\AndrewEmbler'
);

Anything with the namespace \ElectricState\AndrewEmbler will be autoloaded from within the src/ElectricState/AndrewEmbler directory. You can learn more about how this works in the Adding Code to Custom Packages documentation.

Let's see the contents of the PageHandler class.

namespace ElectricState\AndrewEmbler\Page;

use Concrete\Core\Page\Page;
use Concrete\Core\Page\Template;
use Concrete\Core\Page\Type\Type;

class Handler
{


    public function placePost(Page $page)
    {
        $date = $page->getCollectionDatePublic();
        $y = date('Y', strtotime($date));
        $m = date('m', strtotime($date));
        $handle = $page->getCollectionHandle();
        if (!$handle) {
            $handle = $page->getCollectionID();
        }

        $posts = Page::getByPath('/posts');
        $year = Page::getByPath('/' . $y);
        if (!is_object($year) || $year->isError()) {
            $pt = Type::getByHandle('archives_year');
            $year = $posts->add($pt, array(
                'name' => $y
            ), Template::getByHandle('archives'));
            $year->setCanonicalPagePath('/' . $y);
        }
        $month = Page::getByPath('/' . $y . '/' . $m);
        if (!is_object($month) || $month->isError()) {
            $pt = Type::getByHandle('archives_month');
            $month = $year->add($pt, array(
                'name' => $m
            ), Template::getByHandle('archives'));
        }

        $page->move($month);
    }

    public function cleanPostParent(Page $parent)
    {
        if ($parent->getPageTypeHandle() == 'archives_month') {
            $children = $parent->getCollectionChildren();
            if (count($children) == 0) {
                $parent->moveToTrash();

                $year = Page::getByID($parent->getCollectionParentID());
                if (is_object($year) && !$year->isError() && $year->getPageTypeHandle() == 'archives_year') {
                    $children = $year->getCollectionChildren();
                    if (count($children) == 0) {
                        $year->moveToTrash();
                    }
                }
            }
        }
    }
}

placePost simply checks to the date of the page, and builds out a hierarchy at the level beneath the /posts/ node. We add pages of the type "Archives Year" and "Archives Month" – which we'll talk about later. These pages use custom controller to grab lists of pages matching the specific month or year, and inject those into an archive listing page.

Then, once the post is moved into the correct spot, we do something exciting: using the new custom canonical URL functionality added in 5.7.4, we remove "posts/" from the URL. Since we do it at the year level, it automatically filters down to child pages.

Cleaning Up After Ourselves

Triggered when a page is moved, our cleanPostParent() page ensures that no empty month or year archive pages exist. And that's it! With this custom code I've got a nice, blog-style sitemap – but only for those page types and that portion of the site where I really want it.

Loading Conversation