From 2cb02b55e1140e325fa5df320615179287572141 Mon Sep 17 00:00:00 2001 From: Harry Wolff Date: Sat, 22 Feb 2014 21:16:07 -0500 Subject: [PATCH] Custom Page Templates fixes #1969 - creates new ./server/helpers/tempalte.js method which returns the correct view to use when rendering - updates fronted controller to check if a custom page template exists and if so then uses that to render the static page - adds additional class name to body_class helper when a custom page template is being rendered - adds tests to address all new features --- core/server/controllers/frontend.js | 4 +- core/server/helpers/index.js | 24 +++- core/server/helpers/template.js | 22 +++ core/test/unit/frontend_spec.js | 130 +++++++++++------- core/test/unit/server_helpers_index_spec.js | 44 +++++- .../test/unit/server_helpers_template_spec.js | 36 +++++ 6 files changed, 198 insertions(+), 62 deletions(-) diff --git a/core/server/controllers/frontend.js b/core/server/controllers/frontend.js index 3a60e6c06c..609d6e6f8c 100644 --- a/core/server/controllers/frontend.js +++ b/core/server/controllers/frontend.js @@ -15,6 +15,7 @@ var moment = require('moment'), config = require('../config'), errors = require('../errorHandling'), filters = require('../../server/filters'), + template = require('../helpers/template'), frontendControllers, // Cache static post permalink regex @@ -186,7 +187,8 @@ frontendControllers = { filters.doFilter('prePostsRender', post).then(function (post) { api.settings.read('activeTheme').then(function (activeTheme) { var paths = config().paths.availableThemes[activeTheme.value], - view = post.page && paths.hasOwnProperty('page.hbs') ? 'page' : 'post'; + view = template.getThemeViewForPost(paths, post); + res.render(view, {post: post}); }); }); diff --git a/core/server/helpers/index.js b/core/server/helpers/index.js index f34c9b2b16..5ed9d58af9 100644 --- a/core/server/helpers/index.js +++ b/core/server/helpers/index.js @@ -347,6 +347,7 @@ coreHelpers.ghost_script_tags = function () { coreHelpers.body_class = function (options) { /*jslint unparam:true*/ var classes = [], + post = this.post, tags = this.post && this.post.tags ? this.post.tags : this.tags || [], page = this.post && this.post.page ? this.post.page : this.page || false; @@ -366,9 +367,26 @@ coreHelpers.body_class = function (options) { classes.push('page'); } - return filters.doFilter('body_class', classes).then(function (classes) { - var classString = _.reduce(classes, function (memo, item) { return memo + ' ' + item; }, ''); - return new hbs.handlebars.SafeString(classString.trim()); + return api.settings.read('activeTheme').then(function (activeTheme) { + var paths = config().paths.availableThemes[activeTheme.value], + view; + + if (post) { + view = template.getThemeViewForPost(paths, post).split('-'); + + // If this is a page and we have a custom page template + // then we need to modify the class name we inject + // e.g. 'page-contact' is outputted as 'page-template-contact' + if (view[0] === 'page' && view.length > 1) { + view.splice(1, 0, 'template'); + classes.push(view.join('-')); + } + } + + return filters.doFilter('body_class', classes).then(function (classes) { + var classString = _.reduce(classes, function (memo, item) { return memo + ' ' + item; }, ''); + return new hbs.handlebars.SafeString(classString.trim()); + }); }); }; diff --git a/core/server/helpers/template.js b/core/server/helpers/template.js index baba6a9637..622ebab03c 100644 --- a/core/server/helpers/template.js +++ b/core/server/helpers/template.js @@ -23,4 +23,26 @@ templates.execute = function (name, context) { return new hbs.handlebars.SafeString(partial(context)); }; +// Given a theme object and a post object this will return +// which theme template page should be used. +// If given a post object that is a regular post +// it will return 'post'. +// If given a static post object it will return 'page'. +// If given a static post object and a custom page template +// exits it will return that page. +templates.getThemeViewForPost = function (themePaths, post) { + var customPageView = 'page-' + post.slug, + view = 'post'; + + if (post.page) { + if (themePaths.hasOwnProperty(customPageView + '.hbs')) { + view = customPageView; + } else if (themePaths.hasOwnProperty('page.hbs')) { + view = 'page'; + } + } + + return view; +}; + module.exports = templates; \ No newline at end of file diff --git a/core/test/unit/frontend_spec.js b/core/test/unit/frontend_spec.js index e31d519ac9..8823def0ba 100644 --- a/core/test/unit/frontend_spec.js +++ b/core/test/unit/frontend_spec.js @@ -4,7 +4,8 @@ var assert = require('assert'), should = require('should'), sinon = require('sinon'), when = require('when'), - rewire = require("rewire"), + rewire = require('rewire'), + _ = require('lodash'), // Stuff we are testing api = require('../../server/api'), @@ -258,7 +259,7 @@ describe('Frontend Controller', function () { }); describe('single', function () { - var mockStaticPost = { + var mockPosts = [{ 'status': 'published', 'id': 1, 'title': 'Test static page', @@ -266,8 +267,7 @@ describe('Frontend Controller', function () { 'markdown': 'Test static page content', 'page': 1, 'published_at': new Date('2013/12/30').getTime() - }, - mockPost = { + }, { 'status': 'published', 'id': 2, 'title': 'Test normal post', @@ -275,7 +275,15 @@ describe('Frontend Controller', function () { 'markdown': 'The test normal post content', 'page': 0, 'published_at': new Date('2014/1/2').getTime() - }, + }, { + 'status': 'published', + 'id': 3, + 'title': 'About', + 'slug': 'about', + 'markdown': 'This is the about page content', + 'page': 1, + 'published_at': new Date('2014/1/30').getTime() + }], // Helper function to prevent unit tests // from failing via timeout when they // should just immediately fail @@ -287,13 +295,7 @@ describe('Frontend Controller', function () { beforeEach(function () { sandbox.stub(api.posts, 'read', function (args) { - if (args.slug) { - return when(args.slug === mockStaticPost.slug ? mockStaticPost : mockPost); - } else if (args.id) { - return when(args.id === mockStaticPost.id ? mockStaticPost : mockPost); - } else { - return when({}); - } + return when(_.find(mockPosts, args)); }); apiSettingsStub = sandbox.stub(api.settings, 'read'); @@ -312,6 +314,7 @@ describe('Frontend Controller', function () { 'default.hbs': '/content/themes/casper/default.hbs', 'index.hbs': '/content/themes/casper/index.hbs', 'page.hbs': '/content/themes/casper/page.hbs', + 'page-about.hbs': '/content/themes/casper/page-about.hbs', 'post.hbs': '/content/themes/casper/post.hbs' } } @@ -321,6 +324,29 @@ describe('Frontend Controller', function () { describe('static pages', function () { + describe('custom page templates', function () { + beforeEach(function () { + apiSettingsStub.withArgs('permalinks').returns(when({ + value: '/:slug/' + })); + }); + + it('it will render custom page template if it exists', function (done) { + var req = { + path: '/' + mockPosts[2].slug + }, + res = { + render: function (view, context) { + assert.equal(view, 'page-' + mockPosts[2].slug); + assert.equal(context.post, mockPosts[2]); + done(); + } + }; + + frontend.single(req, res, failTest(done)); + }); + }); + describe('permalink set to slug', function () { beforeEach(function () { apiSettingsStub.withArgs('permalinks').returns(when({ @@ -330,12 +356,12 @@ describe('Frontend Controller', function () { it('will render static page via /:slug', function (done) { var req = { - path: '/' + mockStaticPost.slug + path: '/' + mockPosts[0].slug }, res = { render: function (view, context) { assert.equal(view, 'page'); - assert.equal(context.post, mockStaticPost); + assert.equal(context.post, mockPosts[0]); done(); } }; @@ -345,7 +371,7 @@ describe('Frontend Controller', function () { it('will NOT render static page via /YYY/MM/DD/:slug', function (done) { var req = { - path: '/' + ['2012/12/30', mockStaticPost.slug].join('/') + path: '/' + ['2012/12/30', mockPosts[0].slug].join('/') }, res = { render: sinon.spy() @@ -359,13 +385,13 @@ describe('Frontend Controller', function () { it('will redirect static page to admin edit page via /:slug/edit', function (done) { var req = { - path: '/' + [mockStaticPost.slug, 'edit'].join('/') + path: '/' + [mockPosts[0].slug, 'edit'].join('/') }, res = { render: sinon.spy(), redirect: function(arg) { res.render.called.should.be.false; - arg.should.eql(adminEditPagePath + mockStaticPost.id + '/'); + arg.should.eql(adminEditPagePath + mockPosts[0].id + '/'); done(); } }; @@ -375,7 +401,7 @@ describe('Frontend Controller', function () { it('will NOT redirect static page to admin edit page via /YYYY/MM/DD/:slug/edit', function (done) { var req = { - path: '/' + ['2012/12/30', mockStaticPost.slug, 'edit'].join('/') + path: '/' + ['2012/12/30', mockPosts[0].slug, 'edit'].join('/') }, res = { render: sinon.spy(), @@ -399,12 +425,12 @@ describe('Frontend Controller', function () { it('will render static page via /:slug', function (done) { var req = { - path: '/' + mockStaticPost.slug + path: '/' + mockPosts[0].slug }, res = { render: function (view, context) { assert.equal(view, 'page'); - assert.equal(context.post, mockStaticPost); + assert.equal(context.post, mockPosts[0]); done(); } }; @@ -414,7 +440,7 @@ describe('Frontend Controller', function () { it('will NOT render static page via /YYYY/MM/DD/:slug', function (done) { var req = { - path: '/' + ['2012/12/30', mockStaticPost.slug].join('/') + path: '/' + ['2012/12/30', mockPosts[0].slug].join('/') }, res = { render: sinon.spy() @@ -428,13 +454,13 @@ describe('Frontend Controller', function () { it('will redirect static page to admin edit page via /:slug/edit', function (done) { var req = { - path: '/' + [mockStaticPost.slug, 'edit'].join('/') + path: '/' + [mockPosts[0].slug, 'edit'].join('/') }, res = { render: sinon.spy(), redirect: function (arg) { res.render.called.should.be.false; - arg.should.eql(adminEditPagePath + mockStaticPost.id + '/'); + arg.should.eql(adminEditPagePath + mockPosts[0].id + '/'); done(); } }; @@ -444,7 +470,7 @@ describe('Frontend Controller', function () { it('will NOT redirect static page to admin edit page via /YYYY/MM/DD/:slug/edit', function (done) { var req = { - path: '/' + ['2012/12/30', mockStaticPost.slug, 'edit'].join('/') + path: '/' + ['2012/12/30', mockPosts[0].slug, 'edit'].join('/') }, res = { render: sinon.spy(), @@ -470,13 +496,13 @@ describe('Frontend Controller', function () { it('will render post via /:slug', function (done) { var req = { - path: '/' + mockPost.slug + path: '/' + mockPosts[1].slug }, res = { render: function (view, context) { assert.equal(view, 'post'); assert(context.post, 'Context object has post attribute'); - assert.equal(context.post, mockPost); + assert.equal(context.post, mockPosts[1]); done(); } }; @@ -486,7 +512,7 @@ describe('Frontend Controller', function () { it('will NOT render post via /YYYY/MM/DD/:slug', function (done) { var req = { - path: '/' + ['2012/12/30', mockPost.slug].join('/') + path: '/' + ['2012/12/30', mockPosts[1].slug].join('/') }, res = { render: sinon.spy() @@ -501,13 +527,13 @@ describe('Frontend Controller', function () { // Handle Edit append it('will redirect post to admin edit page via /:slug/edit', function (done) { var req = { - path: '/' + [mockPost.slug, 'edit'].join('/') + path: '/' + [mockPosts[1].slug, 'edit'].join('/') }, res = { render: sinon.spy(), redirect: function(arg) { res.render.called.should.be.false; - arg.should.eql(adminEditPagePath + mockPost.id + '/'); + arg.should.eql(adminEditPagePath + mockPosts[1].id + '/'); done(); } }; @@ -517,7 +543,7 @@ describe('Frontend Controller', function () { it('will NOT redirect post to admin edit page via /YYYY/MM/DD/:slug/edit', function (done) { var req = { - path: '/' + ['2012/12/30', mockPost.slug, 'edit'].join('/') + path: '/' + ['2012/12/30', mockPosts[1].slug, 'edit'].join('/') }, res = { render: sinon.spy(), @@ -540,15 +566,15 @@ describe('Frontend Controller', function () { }); it('will render post via /YYYY/MM/DD/:slug', function (done) { - var date = moment(mockPost.published_at).format('YYYY/MM/DD'), + var date = moment(mockPosts[1].published_at).format('YYYY/MM/DD'), req = { - path: '/' + [date, mockPost.slug].join('/') + path: '/' + [date, mockPosts[1].slug].join('/') }, res = { render: function (view, context) { assert.equal(view, 'post'); assert(context.post, 'Context object has post attribute'); - assert.equal(context.post, mockPost); + assert.equal(context.post, mockPosts[1]); done(); } }; @@ -557,9 +583,9 @@ describe('Frontend Controller', function () { }); it('will NOT render post via /YYYY/MM/DD/:slug with non-matching date in url', function (done) { - var date = moment(mockPost.published_at).subtract('days', 1).format('YYYY/MM/DD'), + var date = moment(mockPosts[1].published_at).subtract('days', 1).format('YYYY/MM/DD'), req = { - path: '/' + [date, mockPost.slug].join('/') + path: '/' + [date, mockPosts[1].slug].join('/') }, res = { render: sinon.spy() @@ -573,7 +599,7 @@ describe('Frontend Controller', function () { it('will NOT render post via /:slug', function (done) { var req = { - path: '/' + mockPost.slug + path: '/' + mockPosts[1].slug }, res = { render: sinon.spy() @@ -587,15 +613,15 @@ describe('Frontend Controller', function () { // Handle Edit append it('will redirect post to admin edit page via /YYYY/MM/DD/:slug/edit', function (done) { - var dateFormat = moment(mockPost.published_at).format('YYYY/MM/DD'), + var dateFormat = moment(mockPosts[1].published_at).format('YYYY/MM/DD'), req = { - path: '/' + [dateFormat, mockPost.slug, 'edit'].join('/') + path: '/' + [dateFormat, mockPosts[1].slug, 'edit'].join('/') }, res = { render: sinon.spy(), redirect: function (arg) { res.render.called.should.be.false; - arg.should.eql(adminEditPagePath + mockPost.id + '/'); + arg.should.eql(adminEditPagePath + mockPosts[1].id + '/'); done(); } }; @@ -605,7 +631,7 @@ describe('Frontend Controller', function () { it('will NOT redirect post to admin edit page via /:slug/edit', function (done) { var req = { - path: '/' + [mockPost.slug, 'edit'].join('/') + path: '/' + [mockPosts[1].slug, 'edit'].join('/') }, res = { render: sinon.spy(), @@ -628,15 +654,15 @@ describe('Frontend Controller', function () { }); it('will render post via /:year/:slug', function (done) { - var date = moment(mockPost.published_at).format('YYYY'), + var date = moment(mockPosts[1].published_at).format('YYYY'), req = { - path: '/' + [date, mockPost.slug].join('/') + path: '/' + [date, mockPosts[1].slug].join('/') }, res = { render: function (view, context) { assert.equal(view, 'post'); assert(context.post, 'Context object has post attribute'); - assert.equal(context.post, mockPost); + assert.equal(context.post, mockPosts[1]); done(); } }; @@ -645,9 +671,9 @@ describe('Frontend Controller', function () { }); it('will NOT render post via /YYYY/MM/DD/:slug', function (done) { - var date = moment(mockPost.published_at).format('YYYY/MM/DD'), + var date = moment(mockPosts[1].published_at).format('YYYY/MM/DD'), req = { - path: '/' + [date, mockPost.slug].join('/') + path: '/' + [date, mockPosts[1].slug].join('/') }, res = { render: sinon.spy() @@ -660,9 +686,9 @@ describe('Frontend Controller', function () { }); it('will NOT render post via /:year/slug when year does not match post year', function (done) { - var date = moment(mockPost.published_at).subtract('years', 1).format('YYYY'), + var date = moment(mockPosts[1].published_at).subtract('years', 1).format('YYYY'), req = { - path: '/' + [date, mockPost.slug].join('/') + path: '/' + [date, mockPosts[1].slug].join('/') }, res = { render: sinon.spy() @@ -676,7 +702,7 @@ describe('Frontend Controller', function () { it('will NOT render post via /:slug', function (done) { var req = { - path: '/' + mockPost.slug + path: '/' + mockPosts[1].slug }, res = { render: sinon.spy() @@ -690,15 +716,15 @@ describe('Frontend Controller', function () { // Handle Edit append it('will redirect post to admin edit page via /:year/:slug/edit', function (done) { - var date = moment(mockPost.published_at).format('YYYY'), + var date = moment(mockPosts[1].published_at).format('YYYY'), req = { - path: '/' + [date, mockPost.slug, 'edit'].join('/') + path: '/' + [date, mockPosts[1].slug, 'edit'].join('/') }, res = { render: sinon.spy(), redirect: function (arg) { res.render.called.should.be.false; - arg.should.eql(adminEditPagePath + mockPost.id + '/'); + arg.should.eql(adminEditPagePath + mockPosts[1].id + '/'); done(); } }; @@ -708,7 +734,7 @@ describe('Frontend Controller', function () { it('will NOT redirect post to admin edit page /:slug/edit', function (done) { var req = { - path: '/' + [mockPost.slug, 'edit'].join('/') + path: '/' + [mockPosts[1].slug, 'edit'].join('/') }, res = { render: sinon.spy(), diff --git a/core/test/unit/server_helpers_index_spec.js b/core/test/unit/server_helpers_index_spec.js index abb78ba6c9..ea535803b3 100644 --- a/core/test/unit/server_helpers_index_spec.js +++ b/core/test/unit/server_helpers_index_spec.js @@ -20,6 +20,7 @@ describe('Core Helpers', function () { var sandbox, apiStub, + configStub, overrideConfig = function (newConfig) { helpers.__set__('config', function() { return newConfig; @@ -35,13 +36,28 @@ describe('Core Helpers', function () { }); config = helpers.__get__('config'); - config.theme = sandbox.stub(config, 'theme', function () { - return { - title: 'Ghost', - description: 'Just a blogging platform.', - url: 'http://testurl.com' - }; + configStub = sandbox.stub().returns({ + 'paths': { + 'subdir': '', + 'availableThemes': { + 'casper': { + 'assets': null, + 'default.hbs': '/content/themes/casper/default.hbs', + 'index.hbs': '/content/themes/casper/index.hbs', + 'page.hbs': '/content/themes/casper/page.hbs', + 'page-about.hbs': '/content/themes/casper/page-about.hbs', + 'post.hbs': '/content/themes/casper/post.hbs' + } + } + } }); + _.extend(configStub, config); + configStub.theme = sandbox.stub().returns({ + title: 'Ghost', + description: 'Just a blogging platform.', + url: 'http://testurl.com' + }); + helpers.__set__('config', configStub); helpers.loadCoreHelpers(adminHbs); // Load template helpers in handlebars @@ -310,6 +326,22 @@ describe('Core Helpers', function () { done(); }).then(null, done); }); + + it('can render class for static page with custom template', function (done) { + helpers.body_class.call({ + relativeUrl: '/about', + post: { + page: true, + slug: 'about' + + } + }).then(function (rendered) { + should.exist(rendered); + rendered.string.should.equal('post-template page page-template-about'); + + done(); + }).then(null, done); + }); }); describe('post_class Helper', function () { diff --git a/core/test/unit/server_helpers_template_spec.js b/core/test/unit/server_helpers_template_spec.js index 7ef6179e27..fddc5cf5c5 100644 --- a/core/test/unit/server_helpers_template_spec.js +++ b/core/test/unit/server_helpers_template_spec.js @@ -22,4 +22,40 @@ describe('Helpers Template', function () { should.exist(safeString); safeString.should.have.property('string').and.equal('

Hello world

'); }); + + describe('getThemeViewForPost', function () { + var themePaths = { + 'assets': null, + 'default.hbs': '/content/themes/casper/default.hbs', + 'index.hbs': '/content/themes/casper/index.hbs', + 'page.hbs': '/content/themes/casper/page.hbs', + 'page-about.hbs': '/content/themes/casper/page-about.hbs', + 'post.hbs': '/content/themes/casper/post.hbs' + }, + posts = [{ + page: 1, + slug: 'about' + }, { + page: 1, + slug: 'contact' + }, { + page: 0, + slug: 'test-post' + }]; + + it('will return correct view for a post', function () { + var view = template.getThemeViewForPost(themePaths, posts[0]); + view.should.exist; + view.should.eql('page-about'); + + view = template.getThemeViewForPost(themePaths, posts[1]); + view.should.exist; + view.should.eql('page'); + + view = template.getThemeViewForPost(themePaths, posts[2]); + view.should.exist; + view.should.eql('post'); + }); + + }); }); \ No newline at end of file