Way back when, I wrote this blog post to show you how to customize your URLs to include categories, which you can refer to for my reasoning on needing this change (side note: the lack of support still bothers me). Fast forward a year and a half and Ghost v0.11.0 is out, while my blog sat on 0.5.5 and the tutorial no longer applied; that's unacceptable! Without further delay, here is how it's done.

If you want to skip my steps and see the changes, see my commits on GitHub:

I did not note this in my previous tutorial and should have. This change will not work properly with Dated Permalinks enabled on blog posts, so be sure to disable it.

Starting on line 25 in /core/server/controllers/frontend/post-lookup.js, in the postLookup function, we need to add support for the category in the URL if certain criteria are met. If the category is found, the category is prepended to the slug so that the full path is used in the database lookup. Making the change here allows us to leave the posts model untouched. The additional logic in the if statement maintains support for adding /edit/ to the end of a URL, regardless if it follows Ghost's default convention or this tweaked one.

function postLookup(postUrl) {
    var postPath = url.parse(postUrl).path,
    ....

    if (postUrl.split('/').length > 4 || (postUrl.split('/').length > 3 && postUrl.indexOf('/edit/') == -1)) {
        postPermalink = '/:category' + postPermalink;
        pagePermalink = '/:category' + pagePermalink;
    }

    ...

    // Sanitize params we're going to use to lookup the post.
    params = _.pick(params, 'category', 'slug', 'id');

    if (params['category']) {
        params['slug'] = params['category'] + '/' + params['slug'];
        params = _.omit(params, 'category');
    }

    ...
}

Similar to the previous tutorial, the URL cleanup needs to be tweaked so that our custom forward slash is not removed. On line 63 of /core/server/utils/index.js remove \/| near the beginning of the expression.

string = string.replace(/(\s|\.|@|:|\?|#|\[|\]|!|\$|&|\(|\)|\*|\+|,|;|=|\\|%|<|>|\||\^|~|"|\{|\}|`|–|—)/g, '-')

The next change is to ensure that the URL validation does not fail, which requires at least two changes. The easy part is removing the /:name placeholder on line 100 in /core/server/routes/api.js.

router.get('/slugs/:type', authenticatePrivate, api.http(api.slugs.generate));

The next part is not as clean of a change, but a necessary one. I did not test whether both files needed to be updated, so I did it to both anyway for good measure. On line 14850 of /core/built/assets/ghost.js, the encodeURIComponent needs to be updated to pass the slugified text as a query string parameter instead of part of the URL:

url = this.get('ghostPaths.url').api('slugs', slugType) + '?name=' + encodeURIComponent(textToSlugify);

Since the file is minified, search for encodeURIComponent in /core/built/assets/ghost.min.js. It only occurs once. The change is similar to the unminified version.

return t?(n=this.get("ghostPaths.url").api("slugs",e)+'?name='+encodeURIComponent(t),