From 76e9a8511851ee35bfe1c6940cacb6c8918246a0 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 d1f9ef5b53c..b2af77acbf2 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 fba8a7e80dd..721dc8d11b3 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 41fba15ad9a..3f8ac005db6 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 7082fa04f90..629ed73ffbc 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 52e5e7fb5ac..3b1067db6ff 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 82dff43d66e..1b3a535e486 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 49acd9a00d8..757efcea8ae 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 4b39b144f37..66c040f7b4f 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 a2ae12c4c40..98c56d4f972 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 1188407985d..0725ffc436d 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 f17d9d4cb04..4c6d44875ed 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",