0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-02-03 23:00:14 -05:00

Merge pull request #2243 from hswolff/custom-page-templates

Custom Page Templates
This commit is contained in:
Hannah Wolfe 2014-02-28 19:58:35 +00:00
commit ea7385abbc
6 changed files with 198 additions and 62 deletions

View file

@ -15,6 +15,7 @@ var moment = require('moment'),
config = require('../config'), config = require('../config'),
errors = require('../errorHandling'), errors = require('../errorHandling'),
filters = require('../../server/filters'), filters = require('../../server/filters'),
template = require('../helpers/template'),
frontendControllers, frontendControllers,
// Cache static post permalink regex // Cache static post permalink regex
@ -186,7 +187,8 @@ frontendControllers = {
filters.doFilter('prePostsRender', post).then(function (post) { filters.doFilter('prePostsRender', post).then(function (post) {
api.settings.read('activeTheme').then(function (activeTheme) { api.settings.read('activeTheme').then(function (activeTheme) {
var paths = config().paths.availableThemes[activeTheme.value], 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}); res.render(view, {post: post});
}); });
}); });

View file

@ -347,6 +347,7 @@ coreHelpers.ghost_script_tags = function () {
coreHelpers.body_class = function (options) { coreHelpers.body_class = function (options) {
/*jslint unparam:true*/ /*jslint unparam:true*/
var classes = [], var classes = [],
post = this.post,
tags = this.post && this.post.tags ? this.post.tags : this.tags || [], tags = this.post && this.post.tags ? this.post.tags : this.tags || [],
page = this.post && this.post.page ? this.post.page : this.page || false; page = this.post && this.post.page ? this.post.page : this.page || false;
@ -366,9 +367,26 @@ coreHelpers.body_class = function (options) {
classes.push('page'); classes.push('page');
} }
return filters.doFilter('body_class', classes).then(function (classes) { return api.settings.read('activeTheme').then(function (activeTheme) {
var classString = _.reduce(classes, function (memo, item) { return memo + ' ' + item; }, ''); var paths = config().paths.availableThemes[activeTheme.value],
return new hbs.handlebars.SafeString(classString.trim()); 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)); 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; module.exports = templates;

View file

@ -4,7 +4,8 @@ var assert = require('assert'),
should = require('should'), should = require('should'),
sinon = require('sinon'), sinon = require('sinon'),
when = require('when'), when = require('when'),
rewire = require("rewire"), rewire = require('rewire'),
_ = require('lodash'),
// Stuff we are testing // Stuff we are testing
api = require('../../server/api'), api = require('../../server/api'),
@ -258,7 +259,7 @@ describe('Frontend Controller', function () {
}); });
describe('single', function () { describe('single', function () {
var mockStaticPost = { var mockPosts = [{
'status': 'published', 'status': 'published',
'id': 1, 'id': 1,
'title': 'Test static page', 'title': 'Test static page',
@ -266,8 +267,7 @@ describe('Frontend Controller', function () {
'markdown': 'Test static page content', 'markdown': 'Test static page content',
'page': 1, 'page': 1,
'published_at': new Date('2013/12/30').getTime() 'published_at': new Date('2013/12/30').getTime()
}, }, {
mockPost = {
'status': 'published', 'status': 'published',
'id': 2, 'id': 2,
'title': 'Test normal post', 'title': 'Test normal post',
@ -275,7 +275,15 @@ describe('Frontend Controller', function () {
'markdown': 'The test normal post content', 'markdown': 'The test normal post content',
'page': 0, 'page': 0,
'published_at': new Date('2014/1/2').getTime() '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 // Helper function to prevent unit tests
// from failing via timeout when they // from failing via timeout when they
// should just immediately fail // should just immediately fail
@ -287,13 +295,7 @@ describe('Frontend Controller', function () {
beforeEach(function () { beforeEach(function () {
sandbox.stub(api.posts, 'read', function (args) { sandbox.stub(api.posts, 'read', function (args) {
if (args.slug) { return when(_.find(mockPosts, args));
return when(args.slug === mockStaticPost.slug ? mockStaticPost : mockPost);
} else if (args.id) {
return when(args.id === mockStaticPost.id ? mockStaticPost : mockPost);
} else {
return when({});
}
}); });
apiSettingsStub = sandbox.stub(api.settings, 'read'); apiSettingsStub = sandbox.stub(api.settings, 'read');
@ -312,6 +314,7 @@ describe('Frontend Controller', function () {
'default.hbs': '/content/themes/casper/default.hbs', 'default.hbs': '/content/themes/casper/default.hbs',
'index.hbs': '/content/themes/casper/index.hbs', 'index.hbs': '/content/themes/casper/index.hbs',
'page.hbs': '/content/themes/casper/page.hbs', 'page.hbs': '/content/themes/casper/page.hbs',
'page-about.hbs': '/content/themes/casper/page-about.hbs',
'post.hbs': '/content/themes/casper/post.hbs' 'post.hbs': '/content/themes/casper/post.hbs'
} }
} }
@ -321,6 +324,29 @@ describe('Frontend Controller', function () {
describe('static pages', 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 () { describe('permalink set to slug', function () {
beforeEach(function () { beforeEach(function () {
apiSettingsStub.withArgs('permalinks').returns(when({ apiSettingsStub.withArgs('permalinks').returns(when({
@ -330,12 +356,12 @@ describe('Frontend Controller', function () {
it('will render static page via /:slug', function (done) { it('will render static page via /:slug', function (done) {
var req = { var req = {
path: '/' + mockStaticPost.slug path: '/' + mockPosts[0].slug
}, },
res = { res = {
render: function (view, context) { render: function (view, context) {
assert.equal(view, 'page'); assert.equal(view, 'page');
assert.equal(context.post, mockStaticPost); assert.equal(context.post, mockPosts[0]);
done(); done();
} }
}; };
@ -345,7 +371,7 @@ describe('Frontend Controller', function () {
it('will NOT render static page via /YYY/MM/DD/:slug', function (done) { it('will NOT render static page via /YYY/MM/DD/:slug', function (done) {
var req = { var req = {
path: '/' + ['2012/12/30', mockStaticPost.slug].join('/') path: '/' + ['2012/12/30', mockPosts[0].slug].join('/')
}, },
res = { res = {
render: sinon.spy() 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) { it('will redirect static page to admin edit page via /:slug/edit', function (done) {
var req = { var req = {
path: '/' + [mockStaticPost.slug, 'edit'].join('/') path: '/' + [mockPosts[0].slug, 'edit'].join('/')
}, },
res = { res = {
render: sinon.spy(), render: sinon.spy(),
redirect: function(arg) { redirect: function(arg) {
res.render.called.should.be.false; res.render.called.should.be.false;
arg.should.eql(adminEditPagePath + mockStaticPost.id + '/'); arg.should.eql(adminEditPagePath + mockPosts[0].id + '/');
done(); 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) { it('will NOT redirect static page to admin edit page via /YYYY/MM/DD/:slug/edit', function (done) {
var req = { var req = {
path: '/' + ['2012/12/30', mockStaticPost.slug, 'edit'].join('/') path: '/' + ['2012/12/30', mockPosts[0].slug, 'edit'].join('/')
}, },
res = { res = {
render: sinon.spy(), render: sinon.spy(),
@ -399,12 +425,12 @@ describe('Frontend Controller', function () {
it('will render static page via /:slug', function (done) { it('will render static page via /:slug', function (done) {
var req = { var req = {
path: '/' + mockStaticPost.slug path: '/' + mockPosts[0].slug
}, },
res = { res = {
render: function (view, context) { render: function (view, context) {
assert.equal(view, 'page'); assert.equal(view, 'page');
assert.equal(context.post, mockStaticPost); assert.equal(context.post, mockPosts[0]);
done(); done();
} }
}; };
@ -414,7 +440,7 @@ describe('Frontend Controller', function () {
it('will NOT render static page via /YYYY/MM/DD/:slug', function (done) { it('will NOT render static page via /YYYY/MM/DD/:slug', function (done) {
var req = { var req = {
path: '/' + ['2012/12/30', mockStaticPost.slug].join('/') path: '/' + ['2012/12/30', mockPosts[0].slug].join('/')
}, },
res = { res = {
render: sinon.spy() 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) { it('will redirect static page to admin edit page via /:slug/edit', function (done) {
var req = { var req = {
path: '/' + [mockStaticPost.slug, 'edit'].join('/') path: '/' + [mockPosts[0].slug, 'edit'].join('/')
}, },
res = { res = {
render: sinon.spy(), render: sinon.spy(),
redirect: function (arg) { redirect: function (arg) {
res.render.called.should.be.false; res.render.called.should.be.false;
arg.should.eql(adminEditPagePath + mockStaticPost.id + '/'); arg.should.eql(adminEditPagePath + mockPosts[0].id + '/');
done(); 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) { it('will NOT redirect static page to admin edit page via /YYYY/MM/DD/:slug/edit', function (done) {
var req = { var req = {
path: '/' + ['2012/12/30', mockStaticPost.slug, 'edit'].join('/') path: '/' + ['2012/12/30', mockPosts[0].slug, 'edit'].join('/')
}, },
res = { res = {
render: sinon.spy(), render: sinon.spy(),
@ -470,13 +496,13 @@ describe('Frontend Controller', function () {
it('will render post via /:slug', function (done) { it('will render post via /:slug', function (done) {
var req = { var req = {
path: '/' + mockPost.slug path: '/' + mockPosts[1].slug
}, },
res = { res = {
render: function (view, context) { render: function (view, context) {
assert.equal(view, 'post'); assert.equal(view, 'post');
assert(context.post, 'Context object has post attribute'); assert(context.post, 'Context object has post attribute');
assert.equal(context.post, mockPost); assert.equal(context.post, mockPosts[1]);
done(); done();
} }
}; };
@ -486,7 +512,7 @@ describe('Frontend Controller', function () {
it('will NOT render post via /YYYY/MM/DD/:slug', function (done) { it('will NOT render post via /YYYY/MM/DD/:slug', function (done) {
var req = { var req = {
path: '/' + ['2012/12/30', mockPost.slug].join('/') path: '/' + ['2012/12/30', mockPosts[1].slug].join('/')
}, },
res = { res = {
render: sinon.spy() render: sinon.spy()
@ -501,13 +527,13 @@ describe('Frontend Controller', function () {
// Handle Edit append // Handle Edit append
it('will redirect post to admin edit page via /:slug/edit', function (done) { it('will redirect post to admin edit page via /:slug/edit', function (done) {
var req = { var req = {
path: '/' + [mockPost.slug, 'edit'].join('/') path: '/' + [mockPosts[1].slug, 'edit'].join('/')
}, },
res = { res = {
render: sinon.spy(), render: sinon.spy(),
redirect: function(arg) { redirect: function(arg) {
res.render.called.should.be.false; res.render.called.should.be.false;
arg.should.eql(adminEditPagePath + mockPost.id + '/'); arg.should.eql(adminEditPagePath + mockPosts[1].id + '/');
done(); 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) { it('will NOT redirect post to admin edit page via /YYYY/MM/DD/:slug/edit', function (done) {
var req = { var req = {
path: '/' + ['2012/12/30', mockPost.slug, 'edit'].join('/') path: '/' + ['2012/12/30', mockPosts[1].slug, 'edit'].join('/')
}, },
res = { res = {
render: sinon.spy(), render: sinon.spy(),
@ -540,15 +566,15 @@ describe('Frontend Controller', function () {
}); });
it('will render post via /YYYY/MM/DD/:slug', function (done) { 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 = { req = {
path: '/' + [date, mockPost.slug].join('/') path: '/' + [date, mockPosts[1].slug].join('/')
}, },
res = { res = {
render: function (view, context) { render: function (view, context) {
assert.equal(view, 'post'); assert.equal(view, 'post');
assert(context.post, 'Context object has post attribute'); assert(context.post, 'Context object has post attribute');
assert.equal(context.post, mockPost); assert.equal(context.post, mockPosts[1]);
done(); 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) { 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 = { req = {
path: '/' + [date, mockPost.slug].join('/') path: '/' + [date, mockPosts[1].slug].join('/')
}, },
res = { res = {
render: sinon.spy() render: sinon.spy()
@ -573,7 +599,7 @@ describe('Frontend Controller', function () {
it('will NOT render post via /:slug', function (done) { it('will NOT render post via /:slug', function (done) {
var req = { var req = {
path: '/' + mockPost.slug path: '/' + mockPosts[1].slug
}, },
res = { res = {
render: sinon.spy() render: sinon.spy()
@ -587,15 +613,15 @@ describe('Frontend Controller', function () {
// Handle Edit append // Handle Edit append
it('will redirect post to admin edit page via /YYYY/MM/DD/:slug/edit', function (done) { 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 = { req = {
path: '/' + [dateFormat, mockPost.slug, 'edit'].join('/') path: '/' + [dateFormat, mockPosts[1].slug, 'edit'].join('/')
}, },
res = { res = {
render: sinon.spy(), render: sinon.spy(),
redirect: function (arg) { redirect: function (arg) {
res.render.called.should.be.false; res.render.called.should.be.false;
arg.should.eql(adminEditPagePath + mockPost.id + '/'); arg.should.eql(adminEditPagePath + mockPosts[1].id + '/');
done(); done();
} }
}; };
@ -605,7 +631,7 @@ describe('Frontend Controller', function () {
it('will NOT redirect post to admin edit page via /:slug/edit', function (done) { it('will NOT redirect post to admin edit page via /:slug/edit', function (done) {
var req = { var req = {
path: '/' + [mockPost.slug, 'edit'].join('/') path: '/' + [mockPosts[1].slug, 'edit'].join('/')
}, },
res = { res = {
render: sinon.spy(), render: sinon.spy(),
@ -628,15 +654,15 @@ describe('Frontend Controller', function () {
}); });
it('will render post via /:year/:slug', function (done) { 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 = { req = {
path: '/' + [date, mockPost.slug].join('/') path: '/' + [date, mockPosts[1].slug].join('/')
}, },
res = { res = {
render: function (view, context) { render: function (view, context) {
assert.equal(view, 'post'); assert.equal(view, 'post');
assert(context.post, 'Context object has post attribute'); assert(context.post, 'Context object has post attribute');
assert.equal(context.post, mockPost); assert.equal(context.post, mockPosts[1]);
done(); done();
} }
}; };
@ -645,9 +671,9 @@ describe('Frontend Controller', function () {
}); });
it('will NOT render post via /YYYY/MM/DD/:slug', function (done) { 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 = { req = {
path: '/' + [date, mockPost.slug].join('/') path: '/' + [date, mockPosts[1].slug].join('/')
}, },
res = { res = {
render: sinon.spy() 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) { 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 = { req = {
path: '/' + [date, mockPost.slug].join('/') path: '/' + [date, mockPosts[1].slug].join('/')
}, },
res = { res = {
render: sinon.spy() render: sinon.spy()
@ -676,7 +702,7 @@ describe('Frontend Controller', function () {
it('will NOT render post via /:slug', function (done) { it('will NOT render post via /:slug', function (done) {
var req = { var req = {
path: '/' + mockPost.slug path: '/' + mockPosts[1].slug
}, },
res = { res = {
render: sinon.spy() render: sinon.spy()
@ -690,15 +716,15 @@ describe('Frontend Controller', function () {
// Handle Edit append // Handle Edit append
it('will redirect post to admin edit page via /:year/:slug/edit', function (done) { 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 = { req = {
path: '/' + [date, mockPost.slug, 'edit'].join('/') path: '/' + [date, mockPosts[1].slug, 'edit'].join('/')
}, },
res = { res = {
render: sinon.spy(), render: sinon.spy(),
redirect: function (arg) { redirect: function (arg) {
res.render.called.should.be.false; res.render.called.should.be.false;
arg.should.eql(adminEditPagePath + mockPost.id + '/'); arg.should.eql(adminEditPagePath + mockPosts[1].id + '/');
done(); done();
} }
}; };
@ -708,7 +734,7 @@ describe('Frontend Controller', function () {
it('will NOT redirect post to admin edit page /:slug/edit', function (done) { it('will NOT redirect post to admin edit page /:slug/edit', function (done) {
var req = { var req = {
path: '/' + [mockPost.slug, 'edit'].join('/') path: '/' + [mockPosts[1].slug, 'edit'].join('/')
}, },
res = { res = {
render: sinon.spy(), render: sinon.spy(),

View file

@ -20,6 +20,7 @@ describe('Core Helpers', function () {
var sandbox, var sandbox,
apiStub, apiStub,
configStub,
overrideConfig = function (newConfig) { overrideConfig = function (newConfig) {
helpers.__set__('config', function() { helpers.__set__('config', function() {
return newConfig; return newConfig;
@ -35,13 +36,28 @@ describe('Core Helpers', function () {
}); });
config = helpers.__get__('config'); config = helpers.__get__('config');
config.theme = sandbox.stub(config, 'theme', function () { configStub = sandbox.stub().returns({
return { 'paths': {
title: 'Ghost', 'subdir': '',
description: 'Just a blogging platform.', 'availableThemes': {
url: 'http://testurl.com' '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); helpers.loadCoreHelpers(adminHbs);
// Load template helpers in handlebars // Load template helpers in handlebars
@ -310,6 +326,22 @@ describe('Core Helpers', function () {
done(); done();
}).then(null, 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 () { describe('post_class Helper', function () {

View file

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