From 134e289eb550c81466ee7af2b40e32ae0cd49e48 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 - 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 | 57 +++++++++++++++---- core/server/routes/frontend.js | 24 ++++---- core/test/functional/routes/frontend_spec.js | 36 ++++++++++++ core/test/utils/fixtures/data-generator.js | 6 +- 11 files changed, 133 insertions(+), 27 deletions(-) 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..66c040f7b4f6 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,32 @@ frontendControllers = { }).catch(handleError(next)); }, + preview: function (req, res, next) { + var params = { + uuid: req.params.uuid, + status: 'all', + include: 'author,tags,fields' + }; + + // Query database to find post + 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, post)); + }); + }, + single: function (req, res, next) { var path = req.path, params, @@ -336,16 +381,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, post)); } // If we've checked the path with the static permalink structure 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..d20d4dc3feed 100644 --- a/core/test/functional/routes/frontend_spec.js +++ b/core/test/functional/routes/frontend_spec.js @@ -118,6 +118,42 @@ describe('Frontend Routing', function () { }); }); + describe('Post preview', function () { + 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); + $('body.tag-getting-started').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)); + }); + }); + describe('Single post', function () { it('should redirect without slash', function (done) { request.get('/welcome-to-ghost') 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
    \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",