From 433956c1022812bc06f679c0b5666d7391921f23 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Thu, 16 Apr 2015 13:40:32 -0600 Subject: [PATCH] Add post preview via uuid (/p/:uuid) Refs #5097 - All drafts will show a preview link (this needs real css) - Published posts will redirect - prev/next post helpers only activate on published posts - Powered by ~10 pints between the two of us (@ErisDS, @novaugust) --- core/client/app/models/post.js | 11 +++ core/client/app/styles/layouts/editor.scss | 7 ++ .../app/templates/post-settings-menu.hbs | 5 ++ core/client/app/utils/config-parser.js | 2 + core/server/api/configuration.js | 3 +- core/server/api/posts.js | 6 +- core/server/config/index.js | 3 +- core/server/controllers/frontend.js | 62 ++++++++++++++--- core/server/helpers/prev_next.js | 2 +- core/server/routes/frontend.js | 24 ++++--- core/test/functional/routes/frontend_spec.js | 69 +++++++++++++++---- .../unit/server_helpers/next_post_spec.js | 42 +++++++++++ .../unit/server_helpers/prev_post_spec.js | 40 +++++++++++ core/test/utils/fixtures/data-generator.js | 6 +- 14 files changed, 240 insertions(+), 42 deletions(-) diff --git a/core/client/app/models/post.js b/core/client/app/models/post.js index d1f9ef5b53..b2af77acbf 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 fba8a7e80d..721dc8d11b 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 41fba15ad9..3f8ac005db 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 7082fa04f9..629ed73ffb 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 52e5e7fb5a..3b1067db6f 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 82dff43d66..1b3a535e48 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 49acd9a00d..757efcea8a 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 4b39b144f3..12a0ae0a05 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 6ba4d7e8ca..292b0c3b63 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 a2ae12c4c4..98c56d4f97 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 1188407985..b6021622b5 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,15 +278,54 @@ 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); // All of Ghost's admin depends on the /ghost/ in the url to work properly diff --git a/core/test/unit/server_helpers/next_post_spec.js b/core/test/unit/server_helpers/next_post_spec.js index 9db8e89255..4351483506 100644 --- a/core/test/unit/server_helpers/next_post_spec.js +++ b/core/test/unit/server_helpers/next_post_spec.js @@ -40,6 +40,7 @@ describe('{{next_post}} helper', function () { optionsData = {name: 'next_post', fn: fn, inverse: inverse}; helpers.prev_post.call({html: 'content', + status: 'published', markdown: 'ff', title: 'post2', slug: 'current', @@ -123,4 +124,45 @@ describe('{{next_post}} helper', function () { }); }); }); + + describe('for unpublished post', function () { + var sandbox; + + beforeEach(function () { + sandbox = sinon.sandbox.create(); + utils.loadHelpers(); + sandbox.stub(api.posts, 'read', function (options) { + if (options.include === 'next') { + return Promise.resolve({ + posts: [{slug: '/current/', title: 'post 2', next: {slug: '/next/', title: 'post 3'}}] + }); + } + }); + }); + + afterEach(function () { + sandbox.restore(); + }); + + it('shows \'else\' template', function (done) { + var fn = sinon.spy(), + inverse = sinon.spy(), + optionsData = {name: 'next_post', fn: fn, inverse: inverse}; + + helpers.prev_post.call({html: 'content', + status: 'published', + markdown: 'ff', + title: 'post2', + slug: 'current', + created_at: new Date(0), + url: '/current/'}, optionsData) + .then(function () { + fn.called.should.be.true; + inverse.called.should.be.false; + done(); + }).catch(function (err) { + done(err); + }); + }); + }); }); diff --git a/core/test/unit/server_helpers/prev_post_spec.js b/core/test/unit/server_helpers/prev_post_spec.js index 79e61c34ea..8cebdeb29a 100644 --- a/core/test/unit/server_helpers/prev_post_spec.js +++ b/core/test/unit/server_helpers/prev_post_spec.js @@ -41,6 +41,7 @@ describe('{{prev_post}} helper', function () { optionsData = {name: 'prev_post', fn: fn, inverse: inverse}; helpers.prev_post.call({html: 'content', + status: 'published', markdown: 'ff', title: 'post2', slug: 'current', @@ -79,6 +80,7 @@ describe('{{prev_post}} helper', function () { optionsData = {name: 'prev_post', fn: fn, inverse: inverse}; helpers.prev_post.call({html: 'content', + status: 'published', markdown: 'ff', title: 'post2', slug: 'current', @@ -124,4 +126,42 @@ describe('{{prev_post}} helper', function () { }); }); }); + + describe('for unpublished post', function () { + var sandbox; + + beforeEach(function () { + sandbox = sinon.sandbox.create(); + utils.loadHelpers(); + sandbox.stub(api.posts, 'read', function (options) { + if (options.include === 'previous') { + return Promise.resolve({posts: [{slug: '/current/', title: 'post 2', previous: {slug: '/previous/', title: 'post 1'}}]}); + } + }); + }); + + afterEach(function () { + sandbox.restore(); + }); + + it('shows \'else\' template', function (done) { + var fn = sinon.spy(), + inverse = sinon.spy(), + optionsData = {name: 'prev_post', fn: fn, inverse: inverse}; + + helpers.prev_post.call({html: 'content', + status: 'draft', + markdown: 'ff', + title: 'post2', + slug: 'current', + created_at: new Date(0), + url: '/current/'}, optionsData).then(function () { + fn.called.should.be.false; + inverse.called.should.be.true; + done(); + }).catch(function (err) { + done(err); + }); + }); + }); }); diff --git a/core/test/utils/fixtures/data-generator.js b/core/test/utils/fixtures/data-generator.js index f17d9d4cb0..4c6d44875e 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
    \n
  • test
  • \n
  • line
  • \n
  • items
  • \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

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#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",