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
This commit is contained in:
Harry Wolff 2014-02-22 21:16:07 -05:00
parent 4b64336119
commit 2cb02b55e1
6 changed files with 198 additions and 62 deletions

View File

@ -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});
});
});

View File

@ -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());
});
});
};

View File

@ -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;

View File

@ -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(),

View File

@ -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 () {

View File

@ -22,4 +22,40 @@ describe('Helpers Template', function () {
should.exist(safeString);
safeString.should.have.property('string').and.equal('<h1>Hello world</h1>');
});
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');
});
});
});