Skip to content

Commit

Permalink
✨ Custom post templates (#9073)
Browse files Browse the repository at this point in the history
closes #9060

- Update `gscan` - it now extracts custom templates and exposes them to Ghost
- Add `custom_template` field to post schema w/ 1.13 migration
- Return `templates` array for the active theme in `/themes/` requests
- Users with Author/Editor roles can now request `/themes/`
- Front-end will render `custom_template` for posts if it exists, template priority is now:
  1. `post/page-{{slug}}.hbs`
  2. `{{custom_template}}.hbs`
  3. `post/page.hbs`
  • Loading branch information
kirrg001 authored and kevinansfield committed Oct 10, 2017
1 parent 7999c38 commit 594b0c2
Show file tree
Hide file tree
Showing 17 changed files with 320 additions and 52 deletions.
6 changes: 6 additions & 0 deletions core/server/api/themes.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ var debug = require('ghost-ignition').debug('api:themes'),
* **See:** [API Methods](index.js.html#api%20methods)
*/
themes = {
/**
* Every role can browse all themes. The response contains a list of all available themes in your content folder.
* The active theme get's marked as `active:true` and contains an extra object `templates`, which
* contains the custom templates of the active theme. These custom templates are used to show a dropdown
* in the PSM to be able to choose a custom post template.
*/
browse: function browse(options) {
return apiUtils
// Permissions
Expand Down
24 changes: 14 additions & 10 deletions core/server/controllers/frontend/templates.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,22 +66,26 @@ function getChannelTemplateHierarchy(channelOpts) {
*
* Fetch the ordered list of templates that can be used to render this request.
* 'post' is the default / fallback
* For posts: [post-:slug, post]
* For pages: [page-:slug, page, post]
* For posts: [post-:slug, custom-*, post]
* For pages: [page-:slug, custom-*, page, post]
*
* @param {Object} single
* @param {Object} postObject
* @returns {String[]}
*/
function getSingleTemplateHierarchy(single) {
function getSingleTemplateHierarchy(postObject) {
var templateList = ['post'],
type = 'post';
slugTemplate = 'post-' + postObject.slug;

if (single.page) {
if (postObject.page) {
templateList.unshift('page');
type = 'page';
slugTemplate = 'page-' + postObject.slug;
}

templateList.unshift(type + '-' + single.slug);
if (postObject.custom_template) {
templateList.unshift(postObject.custom_template);
}

templateList.unshift(slugTemplate);

return templateList;
}
Expand Down Expand Up @@ -117,8 +121,8 @@ function pickTemplate(templateList, fallback) {
return template;
}

function getTemplateForSingle(single) {
var templateList = getSingleTemplateHierarchy(single),
function getTemplateForSingle(postObject) {
var templateList = getSingleTemplateHierarchy(postObject),
fallback = templateList[templateList.length - 1];
return pickTemplate(templateList, fallback);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
'use strict';

const Promise = require('bluebird'),
logging = require('../../../../logging'),
commands = require('../../../schema').commands,
table = 'posts',
column = 'custom_template',
message = 'Adding column: ' + table + '.' + column;

module.exports = function addCustomTemplateField(options) {
let transacting = options.transacting;

return transacting.schema.hasTable(table)
.then(function (exists) {
if (!exists) {
return Promise.reject(new Error('Table does not exist!'));
}

return transacting.schema.hasColumn(table, column);
})
.then(function (exists) {
if (exists) {
logging.warn(message);
return Promise.resolve();
}

logging.info(message);
return commands.addColumn(table, column, transacting);
});
};
39 changes: 39 additions & 0 deletions core/server/data/migrations/versions/1.13/2-theme-permissions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
var _ = require('lodash'),
utils = require('../../../schema/fixtures/utils'),
permissions = require('../../../../permissions'),
logging = require('../../../../logging'),
resource = 'theme',
_private = {};

_private.getPermissions = function getPermissions() {
return utils.findModelFixtures('Permission', {object_type: resource});
};

_private.getRelations = function getRelations() {
return utils.findPermissionRelationsForObject(resource);
};

_private.printResult = function printResult(result, message) {
if (result.done === result.expected) {
logging.info(message);
} else {
logging.warn('(' + result.done + '/' + result.expected + ') ' + message);
}
};

module.exports = function addRedirectsPermissions(options) {
var modelToAdd = _private.getPermissions(),
relationToAdd = _private.getRelations(),
localOptions = _.merge({
context: {internal: true}
}, options);

return utils.addFixturesForModel(modelToAdd, localOptions).then(function (result) {
_private.printResult(result, 'Adding permissions fixtures for ' + resource + 's');
return utils.addFixturesForRelation(relationToAdd, localOptions);
}).then(function (result) {
_private.printResult(result, 'Adding permissions_roles fixtures for ' + resource + 's');
}).then(function () {
return permissions.init(localOptions);
});
};
6 changes: 4 additions & 2 deletions core/server/data/schema/fixtures/fixtures.json
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,8 @@
"role": "all",
"client": "all",
"subscriber": ["add"],
"invite": "all"
"invite": "all",
"theme": ["browse"]
},
"Author": {
"post": ["browse", "read", "add"],
Expand All @@ -492,7 +493,8 @@
"user": ["browse", "read"],
"role": ["browse"],
"client": "all",
"subscriber": ["add"]
"subscriber": ["add"],
"theme": ["browse"]
}
}
},
Expand Down
3 changes: 2 additions & 1 deletion core/server/data/schema/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ module.exports = {
og_description: {type: 'string', maxlength: 500, nullable: true},
twitter_image: {type: 'string', maxlength: 2000, nullable: true},
twitter_title: {type: 'string', maxlength: 300, nullable: true},
twitter_description: {type: 'string', maxlength: 500, nullable: true}
twitter_description: {type: 'string', maxlength: 500, nullable: true},
custom_template: {type: 'string', maxlength: 100, nullable: true}
},
users: {
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
Expand Down
20 changes: 10 additions & 10 deletions core/server/themes/active.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@
* No properties marked with an _ should be used directly.
*
*/
var _ = require('lodash'),
join = require('path').join,
var join = require('path').join,
themeConfig = require('./config'),
config = require('../config'),
engine = require('./engine'),
Expand All @@ -42,14 +41,11 @@ class ActiveTheme {
this._packageInfo = loadedTheme['package.json'];
this._partials = checkedTheme.partials;

// @TODO: get gscan to return a template collection for us
this._templates = _.reduce(checkedTheme.files, function (templates, entry) {
var tplMatch = entry.file.match(/(^[^\/]+).hbs$/);
if (tplMatch) {
templates.push(tplMatch[1]);
}
return templates;
}, []);
// all custom .hbs templates (e.g. custom-about)
this._customTemplates = checkedTheme.templates.custom;

// all .hbs templates
this._templates = checkedTheme.templates.all;

// Create a theme config object
this._config = themeConfig.create(this._packageInfo);
Expand All @@ -59,6 +55,10 @@ class ActiveTheme {
return this._name;
}

get customTemplates() {
return this._customTemplates;
}

get path() {
return this._path;
}
Expand Down
15 changes: 12 additions & 3 deletions core/server/themes/to-json.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
var _ = require('lodash'),
themeList = require('./list'),
active = require('./active'),
packages = require('../utils/packages'),
settingsCache = require('../settings/cache');

/**
* Provides a JSON object which can be returned via the API
*
* Provides a JSON object which can be returned via the API.
* You can either request all themes or a specific theme if you pass the `name` argument.
* Furthermore, you can pass a gscan result to filter warnings/errors.
*
* @TODO: settingsCache.get('active_theme') vs. active.get().name
*
* @param {string} [name] - the theme to output
* @param {object} [checkedTheme] - a theme result from gscan
Expand All @@ -15,10 +21,8 @@ module.exports = function toJSON(name, checkedTheme) {

if (!name) {
toFilter = themeList.getAll();
// Default to returning the full list
themeResult = packages.filterPackages(toFilter, settingsCache.get('active_theme'));
} else {
// If we pass in a gscan result, convert this instead
toFilter = {
[name]: themeList.get(name)
};
Expand All @@ -34,5 +38,10 @@ module.exports = function toJSON(name, checkedTheme) {
}
}

// CASE: if you want a JSON response for a single theme, which is not active.
if (_.find(themeResult, {active: true}) && active.get()) {
_.find(themeResult, {active: true}).templates = active.get().customTemplates;
}

return {themes: themeResult};
};
3 changes: 3 additions & 0 deletions core/test/functional/routes/api/posts_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -766,6 +766,7 @@ describe('Post API', function () {
should.exist(jsonResponse.posts[0]);
jsonResponse.posts[0].title = changedTitle;
jsonResponse.posts[0].author = changedAuthor;
jsonResponse.posts[0].custom_template = 'custom-about';

request.put(testUtils.API.getApiQuery('posts/' + testUtils.DataGenerator.Content.posts[0].id + '/'))
.set('Authorization', 'Bearer ' + ownerAccessToken)
Expand All @@ -783,6 +784,8 @@ describe('Post API', function () {
should.exist(putBody);
putBody.posts[0].title.should.eql(changedTitle);
putBody.posts[0].author.should.eql(changedAuthor);
putBody.posts[0].status.should.eql('published');
putBody.posts[0].custom_template.should.eql('custom-about');

testUtils.API.checkResponse(putBody.posts[0], 'post');
done();
Expand Down

0 comments on commit 594b0c2

Please sign in to comment.