diff --git a/core/client/app/models/post.js b/core/client/app/models/post.js index d1f9ef5b53c7..b2af77acbf23 100644 --- a/core/client/app/models/post.js +++ b/core/client/app/models/post.js @@ -35,6 +35,17 @@ var Post = DS.Model.extend(NProgressSaveMixin, ValidationEngine, { return this.get('ghostPaths.url').join(blogUrl, postUrl); }), + previewUrl: Ember.computed('uuid', 'ghostPaths.url', 'config.blogUrl', 'config.routeKeywords.preview', function () { + var blogUrl = this.get('config.blogUrl'), + uuid = this.get('uuid'), + previewKeyword = this.get('config.routeKeywords.preview'); + // New posts don't have a preview + if (!uuid) { + return ''; + } + return this.get('ghostPaths.url').join(blogUrl, previewKeyword, uuid); + }), + scratch: null, titleScratch: null, diff --git a/core/client/app/styles/layouts/editor.scss b/core/client/app/styles/layouts/editor.scss index fba8a7e80dda..721dc8d11b3f 100644 --- a/core/client/app/styles/layouts/editor.scss +++ b/core/client/app/styles/layouts/editor.scss @@ -692,6 +692,13 @@ body.zen { } }//.post-settings-menu +.post-preview-link { + position: absolute; + top: 0; + right: 0; + font-size: 1.3rem; +} + // // Post Settings Menu meta Data diff --git a/core/client/app/templates/post-settings-menu.hbs b/core/client/app/templates/post-settings-menu.hbs index 41fba15ad9a8..3f8ac005db6c 100644 --- a/core/client/app/templates/post-settings-menu.hbs +++ b/core/client/app/templates/post-settings-menu.hbs @@ -11,6 +11,11 @@
+ {{#if model.isDraft}} + + Preview + + {{/if}} {{gh-input class="post-setting-slug" id="url" value=slugValue name="post-setting-slug" focus-out="updateSlug" selectOnClick="true" stopEnterKeyDownPropagation="true"}} diff --git a/core/client/app/utils/config-parser.js b/core/client/app/utils/config-parser.js index 7082fa04f903..629ed73ffbce 100644 --- a/core/client/app/utils/config-parser.js +++ b/core/client/app/utils/config-parser.js @@ -11,6 +11,8 @@ var isNumeric = function (num) { return false; } else if (isNumeric(val)) { return +val; + } else if (val.indexOf('{') === 0) { + return JSON.parse(val); } else { return val; } diff --git a/core/server/api/configuration.js b/core/server/api/configuration.js index 52e5e7fb5ac7..3b1067db6ff1 100644 --- a/core/server/api/configuration.js +++ b/core/server/api/configuration.js @@ -16,7 +16,8 @@ function getValidKeys() { database: config.database.client, mail: _.isObject(config.mail) ? config.mail.transport : '', blogUrl: config.url.replace(/\/$/, ''), - blogTitle: config.theme.title + blogTitle: config.theme.title, + routeKeywords: JSON.stringify(config.routeKeywords) }; return validKeys; diff --git a/core/server/api/posts.js b/core/server/api/posts.js index 82dff43d66e0..1b3a535e486b 100644 --- a/core/server/api/posts.js +++ b/core/server/api/posts.js @@ -64,20 +64,20 @@ posts = { /** * ### Read - * Find a post, by ID or Slug + * Find a post, by ID, UUID, or Slug * * @public * @param {{id_or_slug (required), context, status, include, ...}} options * @return {Promise(Post)} Post */ read: function read(options) { - var attrs = ['id', 'slug', 'status'], + var attrs = ['id', 'slug', 'status', 'uuid'], data = _.pick(options, attrs); options = _.omit(options, attrs); // only published posts if no user is present - if (!(options.context && options.context.user)) { + if (!data.uuid && !(options.context && options.context.user)) { data.status = 'published'; } diff --git a/core/server/config/index.js b/core/server/config/index.js index 49acd9a00d83..757efcea8aee 100644 --- a/core/server/config/index.js +++ b/core/server/config/index.js @@ -205,7 +205,8 @@ ConfigManager.prototype.set = function (config) { routeKeywords: { tag: 'tag', author: 'author', - page: 'page' + page: 'page', + preview: 'p' }, slugs: { // Used by generateSlug to generate slugs for posts, tags, users, .. diff --git a/core/server/controllers/frontend.js b/core/server/controllers/frontend.js index 4b39b144f371..12a0ae0a05b0 100644 --- a/core/server/controllers/frontend.js +++ b/core/server/controllers/frontend.js @@ -123,6 +123,25 @@ function getActiveThemePaths() { }); } +/* +* Sets the response context around a post and renders it +* with the current theme's post view. Used by post preview +* and single post methods. +* Returns a function that takes the post to be rendered. +*/ +function renderPost(req, res) { + return function (post) { + return getActiveThemePaths().then(function (paths) { + var view = template.getThemeViewForPost(paths, post), + response = formatResponse(post); + + setResponseContext(req, res, response); + + res.render(view, response); + }); + }; +} + frontendControllers = { homepage: function (req, res, next) { // Parse the page number @@ -271,6 +290,37 @@ frontendControllers = { }).catch(handleError(next)); }, + preview: function (req, res, next) { + var params = { + uuid: req.params.uuid, + status: 'all', + include: 'author,tags,fields' + }; + + api.posts.read(params).then(function (result) { + var post = result.posts[0]; + + if (!post) { + return next(); + } + + if (post.status === 'published') { + return res.redirect(301, config.urlFor('post', {post: post})); + } + + setReqCtx(req, post); + + filters.doFilter('prePostsRender', post, res.locals) + .then(renderPost(req, res)); + }).catch(function (err) { + if (err.errorType === 'NotFoundError') { + return next(); + } + + return handleError(next)(err); + }); + }, + single: function (req, res, next) { var path = req.path, params, @@ -336,16 +386,8 @@ frontendControllers = { setReqCtx(req, post); - filters.doFilter('prePostsRender', post, res.locals).then(function (post) { - getActiveThemePaths().then(function (paths) { - var view = template.getThemeViewForPost(paths, post), - response = formatResponse(post); - - setResponseContext(req, res, response); - - res.render(view, response); - }); - }); + filters.doFilter('prePostsRender', post, res.locals) + .then(renderPost(req, res)); } // If we've checked the path with the static permalink structure diff --git a/core/server/helpers/prev_next.js b/core/server/helpers/prev_next.js index 6ba4d7e8ca9a..292b0c3b6307 100644 --- a/core/server/helpers/prev_next.js +++ b/core/server/helpers/prev_next.js @@ -32,7 +32,7 @@ prevNext = function (options) { include: options.name === 'prev_post' ? 'previous' : 'next' }; - if (schema.isPost(this)) { + if (schema.isPost(this) && this.status === 'published') { apiOptions.slug = this.slug; return fetch(apiOptions, options); } else { diff --git a/core/server/routes/frontend.js b/core/server/routes/frontend.js index a2ae12c4c400..98c56d4f9720 100644 --- a/core/server/routes/frontend.js +++ b/core/server/routes/frontend.js @@ -7,7 +7,8 @@ var frontend = require('../controllers/frontend'), frontendRoutes = function () { var router = express.Router(), - subdir = config.paths.subdir; + subdir = config.paths.subdir, + routeKeywords = config.routeKeywords; // ### Admin routes router.get(/^\/(logout|signout)\/$/, function redirect(req, res) { @@ -37,19 +38,22 @@ frontendRoutes = function () { }); // Tags - router.get('/' + config.routeKeywords.tag + '/:slug/rss/', frontend.rss); - router.get('/' + config.routeKeywords.tag + '/:slug/rss/:page/', frontend.rss); - router.get('/' + config.routeKeywords.tag + '/:slug/' + config.routeKeywords.page + '/:page/', frontend.tag); - router.get('/' + config.routeKeywords.tag + '/:slug/', frontend.tag); + router.get('/' + routeKeywords.tag + '/:slug/rss/', frontend.rss); + router.get('/' + routeKeywords.tag + '/:slug/rss/:page/', frontend.rss); + router.get('/' + routeKeywords.tag + '/:slug/' + routeKeywords.page + '/:page/', frontend.tag); + router.get('/' + routeKeywords.tag + '/:slug/', frontend.tag); // Authors - router.get('/' + config.routeKeywords.author + '/:slug/rss/', frontend.rss); - router.get('/' + config.routeKeywords.author + '/:slug/rss/:page/', frontend.rss); - router.get('/' + config.routeKeywords.author + '/:slug/' + config.routeKeywords.page + '/:page/', frontend.author); - router.get('/' + config.routeKeywords.author + '/:slug/', frontend.author); + router.get('/' + routeKeywords.author + '/:slug/rss/', frontend.rss); + router.get('/' + routeKeywords.author + '/:slug/rss/:page/', frontend.rss); + router.get('/' + routeKeywords.author + '/:slug/' + routeKeywords.page + '/:page/', frontend.author); + router.get('/' + routeKeywords.author + '/:slug/', frontend.author); + + // Post Live Preview + router.get('/' + routeKeywords.preview + '/:uuid', frontend.preview); // Default - router.get('/' + config.routeKeywords.page + '/:page/', frontend.homepage); + router.get('/' + routeKeywords.page + '/:page/', frontend.homepage); router.get('/', frontend.homepage); router.get('*', frontend.single); diff --git a/core/test/functional/routes/frontend_spec.js b/core/test/functional/routes/frontend_spec.js index 1188407985d8..d47a8e2040df 100644 --- a/core/test/functional/routes/frontend_spec.js +++ b/core/test/functional/routes/frontend_spec.js @@ -29,6 +29,14 @@ describe('Frontend Routing', function () { }; } + function addPosts (done) { + testUtils.initData().then(function () { + return testUtils.fixtures.insertPosts(); + }).then(function () { + done(); + }); + } + before(function (done) { ghost().then(function (ghostServer) { // Setup the request object with the ghost express app @@ -249,13 +257,7 @@ describe('Frontend Routing', function () { }); describe('Static page', function () { - before(function (done) { - testUtils.initData().then(function () { - return testUtils.fixtures.insertPosts(); - }).then(function () { - done(); - }); - }); + before(addPosts); after(testUtils.teardown); @@ -276,14 +278,53 @@ describe('Frontend Routing', function () { }); }); - describe('Post with Ghost in the url', function () { - before(function (done) { - testUtils.initData().then(function () { - return testUtils.fixtures.insertPosts(); - }).then(function () { - done(); - }); + describe('Post preview', function () { + before(addPosts); + + after(testUtils.teardown); + + it('should display draft posts accessed via uuid', function (done) { + request.get('/p/d52c42ae-2755-455c-80ec-70b2ec55c903/') + .expect('Content-Type', /html/) + .expect(200) + .end(function (err, res) { + if (err) { + return done(err); + } + + var $ = cheerio.load(res.text); + + should.not.exist(res.headers['x-cache-invalidate']); + should.not.exist(res.headers['X-CSRF-Token']); + should.not.exist(res.headers['set-cookie']); + should.exist(res.headers.date); + + $('title').text().should.equal('Not finished yet'); + $('.content .post').length.should.equal(1); + $('.poweredby').text().should.equal('Proudly published with Ghost'); + $('body.post-template').length.should.equal(1); + $('article.post').length.should.equal(1); + + done(); + }); + }); + + it('should redirect published posts to their live url', function (done) { + request.get('/p/2ac6b4f6-e1f3-406c-9247-c94a0496d39d/') + .expect(301) + .expect('Location', '/short-and-sweet/') + .end(doEnd(done)); + }); + + it('404s unknown uuids', function (done) { + request.get('/p/aac6b4f6-e1f3-406c-9247-c94a0496d39f/') + .expect(404) + .end(doEnd(done)); }); + }); + + describe('Post with Ghost in the url', function () { + before(addPosts); after(testUtils.teardown); diff --git a/core/test/utils/fixtures/data-generator.js b/core/test/utils/fixtures/data-generator.js index f17d9d4cb044..4c6d44875ed1 100644 --- a/core/test/utils/fixtures/data-generator.js +++ b/core/test/utils/fixtures/data-generator.js @@ -25,13 +25,15 @@ DataGenerator.Content = { html: "

testing

\n\n

mctesters

\n\n", image: "http://placekitten.com/500/200", meta_description: "test stuff", - published_at: new Date("2015-01-03") + published_at: new Date("2015-01-03"), + uuid: "2ac6b4f6-e1f3-406c-9247-c94a0496d39d" }, { title: "Not finished yet", slug: "unfinished", markdown: "

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

#header h1 a{display: block;width: 300px;height: 80px;}
", - status: "draft" + status: "draft", + uuid: "d52c42ae-2755-455c-80ec-70b2ec55c903" }, { title: "Not so short, bit complex",