Merge branch 'master' into ember

Conflicts:
	Gruntfile.js
	core/client/assets/lib/showdown/extensions/ghostdown.js
	core/client/views/editor.js
	core/clientold/assets/lib/showdown/extensions/ghostdown.js
	core/shared/lib/showdown/extensions/ghostimagepreview.js
	core/test/unit/showdown_client_integrated_spec.js
	core/test/unit/showdown_ghostimagepreview_spec.js
This commit is contained in:
Hannah Wolfe 2014-04-07 22:26:53 +01:00
commit c746a88b2e
82 changed files with 3542 additions and 2111 deletions

3
.bowerrc Normal file
View File

@ -0,0 +1,3 @@
{
"directory": "bower_components"
}

2
.gitmodules vendored
View File

@ -1,3 +1,3 @@
[submodule "content/themes/casper"]
path = content/themes/casper
url = git://github.com/TryGhost/Casper.git
url = https://github.com/TryGhost/Casper.git

View File

@ -2,20 +2,20 @@ language: node_js
node_js:
- "0.10"
env:
- DB=sqlite3
- DB=mysql
- DB=pg
- DB=sqlite3 NODE_ENV=testing
- DB=mysql NODE_ENV=testing-mysql
- DB=pg NODE_ENV=testing-pg
matrix:
allow_failures:
- env: DB=pg
- env: DB=pg NODE_ENV=testing-pg
before_install:
- git clone git://github.com/n1k0/casperjs.git ~/casperjs
- cd ~/casperjs
- git checkout tags/1.1-beta3
- export PATH=$PATH:`pwd`/bin
- cd -
- if [ $DB == "mysql" ]; then mysql -e 'create database ghost_travis'; fi
- if [ $DB == "pg" ]; then npm install pg; psql -c 'create database ghost_travis;' -U postgres; fi
- if [ $DB == "mysql" ]; then mysql -e 'create database ghost_testing'; fi
- if [ $DB == "pg" ]; then npm install pg; psql -c 'create database ghost_testing;' -U postgres; fi
before_script:
- phantomjs --version
- casperjs --version

View File

@ -55,14 +55,14 @@ Guidelines for bug reports:
helpful thing in the world is if we can *see* what you're talking about.
Use [LICEcap](http://www.cockos.com/licecap/) to quickly and easily record a short screencast (24fps) and save it as an animated gif! Embed it directly into your GitHub issue. Kapow.
5. Use the Bug Report template below or [click this link](https://github.com/TryGhost/Ghost/issues/new?title=Bug%3A&body=%23%23%23%20Issue%20Summary%0A%0A%23%23%23%20Steps%20to%20Reproduce%0A%0A1.%20This%20is%20the%20first%20step%0A%0AThis%20is%20a%20bug%20because...%0A%0A%23%23%23%20Technical%20details%0A%0A*%20Ghost%20Version%3A%20master%20-%20latest%20commit%3A%20%20INSERT%20COMMIT%20REF%0A*%20Client%20OS%3A%20%0A*%20Server%20OS%3A%20%0A*%20Node%20Version%3A%20%0A*%20Browser%3A) to start creating a bug report with the template automatically.
5. Use the Bug Report template below or [click this link](https://github.com/TryGhost/Ghost/issues/new?title=Bug%3A&body=%23%23%23%20Issue%20Summary%0A%0A%23%23%23%20Steps%20to%20Reproduce%0A%0A1.%20This%20is%20the%20first%20step%0A%0AThis%20is%20a%20bug%20because...%0A%0A%23%23%23%20Technical%20details%0A%0A*%20Ghost%20Version%3A%20master%20-%20latest%20commit%3A%20%20INSERT%20COMMIT%20REF%0A*%20Client%20OS%3A%20%0A*%20Server%20OS%3A%20%0A*%20Node%20Version%3A%20%0A*%20Browser%3A%20%0A*%20Database%3A) to start creating a bug report with the template automatically.
A good bug report shouldn't leave others needing to chase you up for more information. Be sure to include the
details of your environment.
Here is a [real example](https://github.com/TryGhost/Ghost/issues/413)
Template Example ([click to use](https://github.com/TryGhost/Ghost/issues/new?title=Bug%3A&body=%23%23%23%20Issue%20Summary%0A%0A%23%23%23%20Steps%20to%20Reproduce%0A%0A1.%20This%20is%20the%20first%20step%0A%0AThis%20is%20a%20bug%20because...%0A%0A%23%23%23%20Technical%20details%0A%0A*%20Ghost%20Version%3A%20master%20-%20latest%20commit%3A%20%20INSERT%20COMMIT%20REF%0A*%20Client%20OS%3A%20%0A*%20Server%20OS%3A%20%0A*%20Node%20Version%3A%20%0A*%20Browser%3A)):
Template Example ([click to use](https://github.com/TryGhost/Ghost/issues/new?title=Bug%3A&body=%23%23%23%20Issue%20Summary%0A%0A%23%23%23%20Steps%20to%20Reproduce%0A%0A1.%20This%20is%20the%20first%20step%0A%0AThis%20is%20a%20bug%20because...%0A%0A%23%23%23%20Technical%20details%0A%0A*%20Ghost%20Version%3A%20master%20-%20latest%20commit%3A%20%20INSERT%20COMMIT%20REF%0A*%20Client%20OS%3A%20%0A*%20Server%20OS%3A%20%0A*%20Node%20Version%3A%20%0A*%20Browser%3A%20%0A*%20Database%3A)):
```
Short and descriptive example bug report title
@ -87,6 +87,7 @@ reported. Especially, why do you consider this to be a bug? What do you expect t
* Server OS: CentOS 6.4
* Node Version: 0.10.16
* Browser: Chrome 29.0.1547.57
* Database: SQLite / MySQL / postgres
```
<a name="features"></a>
@ -190,12 +191,13 @@ developing Ghost.
1. Check you have the pre-requisites listed above!
1. Clone the git repo
1. cd into the project folder
1. Run `npm install -g grunt-cli`
1. Run `npm install`.
1. Run `npm install -g grunt-cli` - to make it possible to run grunt commands
1. Run `npm install` - you need all the dependencies, so do not use the `--production` flag mentioned in user install guides
* If the install fails with errors to do with "node-gyp rebuild" or "SQLite3", follow the SQLite3 install
instructions below this list
* Usually if you're within vagrant, and have installed the guest plugins and updated that, this will not happen
1. Run `grunt init` from the root - copies assets and compiles Handlebars templates
1. Run `grunt init` from the root - updates bower dependencies, copies assets and compiles Handlebars templates
1. If you're going to run in production mode, you also need to run `grunt prod`
1. Run `npm start` from the root to start the server.
If something goes wrong, please see the
@ -204,7 +206,7 @@ If something goes wrong, please see the
### Developer Tips
Whilst developing, you can take advantage of the [Grunt toolkit](https://github.com/TryGhost/Ghost/wiki/Grunt-Toolkit) to automatically compile assets, such as handlebar templates, stylesheets and javascripts. Some useful commands include:
- `grunt dev` => Automatically compile assets in development environment
- `grunt prod` => Automatically compile assets in production environment
- `grunt prod` => Automatically compile assets for the production environment
- `grunt watch` => Automatically compile handlebars
Addresses for development:
@ -218,15 +220,15 @@ or more of the following:
* `npm install` - fetch any new dependencies
* `git submodule update` - fetch the latest changes to Casper (the default theme)
* `grunt` - will recompile handlebars templates for the admin (as long as you have previously
run `grunt init` to install bower dependencies)
* `grunt init` - will fetch bower dependencies and recompile handlebars templates for the admin
* delete content/data/*.db - delete the database and allow Ghost to recreate the fixtures
### Key Branches & Tags
- **[master](https://github.com/TryGhost/Ghost)** is the bleeding edge development branch. All work on the next
release is here.
- **[gh-pages](http://tryghost.github.io/Ghost)** is The Ghost Guide documentation for Getting Started with Ghost.
release is here. Do **NOT** use this branch for a production site.
- **[stable](https://github.com/TryGhost/Ghost/tree/stable)** contains the latest release of Ghost. This branch may be used in production.
- **[gh-pages](http://github.com/TryGhost/Ghost/tree/gh-pages)** contains [The Ghost Guide](http://docs.ghost.org) documentation for Getting Started with Ghost.
## Grunt Toolkit
@ -245,7 +247,7 @@ When cloning from GitHub be sure to use SSH and to run `git submodule update --i
### Ghost doesn't do anything - I get a blank screen
Sounds like you probably didn't run the right grunt command for building assets
Sounds like you probably didn't run the right grunt command for building assets. You may need to run `grunt init` and if using production mode, `grunt prod` as well.
### SQLite3 doesn't install properly during npm install

View File

@ -65,21 +65,25 @@ var path = require('path'),
concat: {
files: [
'core/clientold/*.js',
'core/clientold/helpers/*.js',
'core/clientold/models/*.js',
'core/clientold/tpl/*.js',
'core/clientold/views/*.js'
'core/clientold/**/*.js'
],
tasks: ['concat']
},
'ghost-ui': {
files: [
// Ghost UI CSS
'bower_components/ghost-ui/dist/css/*.css'
],
tasks: ['copy:dev']
},
livereload: {
files: [
// Theme CSS
'content/themes/casper/css/*.css',
// Ghost UI CSS
'bower_components/ghost-ui/dist/css/*.css',
// Theme JS
'content/themes/casper/js/*.js',
// Client CSS
'core/client/assets/css/*.css',
// Admin JS
'core/built/scripts/*.js'
],
@ -300,12 +304,8 @@ var path = require('path'),
]
},
api: {
src: ['core/test/functional/api/*_test.js']
},
routes: {
src: ['core/test/functional/routes/*_test.js']
src: ['core/test/functional/routes/**/*_test.js']
}
},
@ -534,9 +534,8 @@ var path = require('path'),
'bower_components/showdown/src/showdown.js',
'bower_components/validator-js/validator.js',
'core/clientold/assets/lib/showdown/extensions/ghostdown.js',
'core/shared/lib/showdown/extensions/typography.js',
'core/shared/lib/showdown/extensions/github.js',
'core/shared/lib/showdown/extensions/ghostimagepreview.js',
'core/shared/lib/showdown/extensions/ghostgfm.js',
// ToDo: Remove or replace
'core/clientold/assets/vendor/shortcuts.js',
@ -553,7 +552,14 @@ var path = require('path'),
'core/clientold/mobile-interactions.js',
'core/clientold/toggle.js',
'core/clientold/markdown-actions.js',
'core/clientold/helpers/index.js'
'core/clientold/helpers/index.js',
'core/clientold/assets/lib/editor/index.js',
'core/clientold/assets/lib/editor/markerManager.js',
'core/clientold/assets/lib/editor/uploadManager.js',
'core/clientold/assets/lib/editor/markdownEditor.js',
'core/clientold/assets/lib/editor/htmlPreview.js',
'core/clientold/assets/lib/editor/scrollHandler.js',
'core/clientold/assets/lib/editor/mobileCodeMirror.js'
],
'core/built/scripts/templates.js': [
@ -587,9 +593,8 @@ var path = require('path'),
'bower_components/showdown/src/showdown.js',
'bower_components/moment/moment.js',
'core/clientold/assets/lib/showdown/extensions/ghostdown.js',
'core/shared/lib/showdown/extensions/typography.js',
'core/shared/lib/showdown/extensions/github.js'
'core/shared/lib/showdown/extensions/ghostimagepreview.js',
'core/shared/lib/showdown/extensions/ghostgfm.js',
]
}
},
@ -613,9 +618,8 @@ var path = require('path'),
'bower_components/showdown/src/showdown.js',
'bower_components/validator-js/validator.js',
'core/clientold/assets/lib/showdown/extensions/ghostdown.js',
'core/shared/lib/showdown/extensions/typography.js',
'core/shared/lib/showdown/extensions/github.js',
'core/shared/lib/showdown/extensions/ghostimagepreview.js',
'core/shared/lib/showdown/extensions/ghostgfm.js',
// ToDo: Remove or replace
'core/clientold/assets/vendor/shortcuts.js',
@ -632,6 +636,14 @@ var path = require('path'),
'core/clientold/markdown-actions.js',
'core/clientold/helpers/index.js',
'core/clientold/assets/lib/editor/index.js',
'core/clientold/assets/lib/editor/markerManager.js',
'core/clientold/assets/lib/editor/uploadManager.js',
'core/clientold/assets/lib/editor/markdownEditor.js',
'core/clientold/assets/lib/editor/htmlPreview.js',
'core/clientold/assets/lib/editor/scrollHandler.js',
'core/clientold/assets/lib/editor/mobileCodeMirror.js',
'core/clientold/tpl/hbs-tpl.js',
'core/clientold/models/**/*.js',
@ -649,7 +661,8 @@ var path = require('path'),
uglify: {
prod: {
files: {
'core/built/scripts/ghost.min.js': 'core/built/scripts/ghost.js'
'core/built/scripts/ghost.min.js': 'core/built/scripts/ghost.js',
'core/built/public/jquery.min.js': 'core/built/public/jquery.js'
}
}
}
@ -657,11 +670,10 @@ var path = require('path'),
grunt.initConfig(cfg);
// ## Custom Tasks
grunt.registerTask('setTestEnv', 'Use "testing" Ghost config; unless we are running on travis (then show queries for debugging)', function () {
process.env.NODE_ENV = process.env.TRAVIS ? 'travis-' + process.env.DB : 'testing';
process.env.NODE_ENV = process.env.TRAVIS ? process.env.NODE_ENV : 'testing';
cfg.express.test.options.node_env = process.env.NODE_ENV;
});
@ -994,11 +1006,9 @@ var path = require('path'),
grunt.registerTask('test-functional', 'Run functional interface tests (CasperJS)', ['clean:test', 'setTestEnv', 'loadConfig', 'copy:dev', 'express:test', 'spawn-casperjs', 'express:test:stop']);
grunt.registerTask('test-api', 'Run functional api tests (mocha)', ['clean:test', 'setTestEnv', 'loadConfig', 'express:test', 'mochacli:api', 'express:test:stop']);
grunt.registerTask('test-routes', 'Run functional route tests (mocha)', ['clean:test', 'setTestEnv', 'loadConfig', 'mochacli:routes']);
grunt.registerTask('test-routes', 'Run functional route tests (mocha)', ['clean:test', 'setTestEnv', 'loadConfig', 'express:test', 'mochacli:routes', 'express:test:stop']);
grunt.registerTask('validate', 'Run tests and lint code', ['jshint', 'test-routes', 'test-unit', 'test-api', 'test-integration', 'test-functional']);
grunt.registerTask('validate', 'Run tests and lint code', ['jshint', 'test-routes', 'test-unit', 'test-integration', 'test-functional']);
// ### Coverage report for Unit and Integration Tests

View File

@ -1,4 +1,4 @@
# [Ghost](https://github.com/TryGhost/Ghost) [![Build Status](https://travis-ci.org/TryGhost/Ghost.png?branch=master)](https://travis-ci.org/TryGhost/Ghost)
# [Ghost](https://github.com/TryGhost/Ghost) [![Build Status](https://travis-ci.org/TryGhost/Ghost.svg?branch=master)](https://travis-ci.org/TryGhost/Ghost)
Ghost is a free, open, simple blogging platform that's available to anyone who wants to use it. Lovingly created and maintained by [John O'Nolan](http://twitter.com/JohnONolan) + [Hannah Wolfe](http://twitter.com/ErisDS) + an amazing group of [contributors](https://github.com/TryGhost/Ghost/contributors).
@ -8,8 +8,6 @@ Visit the project's website at <http://ghost.org> &bull; docs on <http://docs.gh
Want to report a bug, request a feature, or help us build or translate Ghost? Check out our in depth guide to [Contributing to Ghost](https://github.com/TryGhost/Ghost/blob/master/CONTRIBUTING.md). We need all the help we can get! You can also join in with our [community](https://github.com/TryGhost/Ghost#community) to keep up-to-date and meet other Ghosters.
## Getting Started
There are **two** main ways to get started with Ghost, take care to use the method which best suits your needs.
@ -39,8 +37,18 @@ Check out the [Documentation](http://docs.ghost.org/) for more detailed instruct
If you're a theme, app or core developer, or someone comfortable getting up and running from a `git clone`, this method is for you.
If you clone the GitHub repository, you will need to build a number of assets, such as SASS and JavaScript templates. This requires you to have Ruby and a number of other pre-requisites.
Full instructions can be found in the [Contributing Guide](https://github.com/TryGhost/Ghost/blob/master/CONTRIBUTING.md) under the heading "[Working on Ghost Core](https://github.com/TryGhost/Ghost/blob/master/CONTRIBUTING.md#working-on-ghost-core)".
If you clone the GitHub repository, you will need to build a number of assets using grunt.
Please do **NOT** use the master branch of Ghost in production. If you are using git to deploy to production, please use the latest [release](https://github.com/TryGhost/Ghost/releases) or the [stable](https://github.com/TryGhost/Ghost/tree/stable) branch which contains the latest release.
#### Quickstart:
1. `npm install -g grunt-cli`
1. `npm install`
1. `grunt init` (and `grunt prod` if you want to run Ghost in production mode)
1. `npm start`
Full instructions & troubleshooting tips can be found in the [Contributing Guide](https://github.com/TryGhost/Ghost/blob/master/CONTRIBUTING.md) under the heading "[Working on Ghost Core](https://github.com/TryGhost/Ghost/blob/master/CONTRIBUTING.md#working-on-ghost-core)".
Check out the [Documentation](http://docs.ghost.org/) for more detailed instructions, or get in touch via the [forum](http://ghost.org/forum) if you get stuck.

View File

@ -2,12 +2,12 @@
"name": "ghost",
"dependencies": {
"backbone": "1.0.0",
"codemirror": "3.15.0",
"codemirror": "4.0.1",
"Countable": "2.0.2",
"ember": "1.5.0",
"ember-resolver": "git://github.com/stefanpenner/ember-jj-abrams-resolver.git#181251821cf513bb58d3e192faa13245a816f75e",
"fastclick": "1.0.0",
"ghost-ui": "0.1.2",
"ghost-ui": "0.1.3",
"handlebars": "1.3.0",
"ic-ajax": "1.0.1",
"jquery": "1.11.0",
@ -17,10 +17,10 @@
"lodash": "2.4.1",
"moment": "2.4.0",
"nprogress": "0.1.2",
"showdown": "0.3.1",
"showdown": "https://github.com/ErisDS/showdown.git#v0.3.2-ghost",
"validator-js": "3.4.0"
},
"resolutions": {
"ember": "~1.4.0"
}
}
}

View File

@ -85,34 +85,17 @@ config = {
logging: false
},
// ### Travis
// Automated testing run through GitHub
'travis-sqlite3': {
url: 'http://127.0.0.1:2369',
database: {
client: 'sqlite3',
connection: {
filename: path.join(__dirname, '/content/data/ghost-travis.db')
}
},
server: {
host: '127.0.0.1',
port: '2369'
},
logging: false
},
// ### Travis
// Automated testing run through GitHub
'travis-mysql': {
// ### Testing MySQL
// Used by Travis - Automated testing run through GitHub
'testing-mysql': {
url: 'http://127.0.0.1:2369',
database: {
client: 'mysql',
connection: {
host : '127.0.0.1',
user : 'travis',
user : 'root',
password : '',
database : 'ghost_travis',
database : 'ghost_testing',
charset : 'utf8'
}
},
@ -123,9 +106,9 @@ config = {
logging: false
},
// ### Travis
// Automated testing run through GitHub
'travis-pg': {
// ### Testing pg
// Used by Travis - Automated testing run through GitHub
'testing-pg': {
url: 'http://127.0.0.1:2369',
database: {
client: 'pg',
@ -133,7 +116,7 @@ config = {
host : '127.0.0.1',
user : 'postgres',
password : '',
database : 'ghost_travis',
database : 'ghost_testing',
charset : 'utf8'
}
},

@ -1 +1 @@
Subproject commit 3c1b7d68ec63f7cf7621f3aa8e30b5c01961e145
Subproject commit a41588f3e5d0ff5393ec4c70713e8905b9c62526

View File

@ -0,0 +1,44 @@
// # Ghost Editor HTML Preview
//
// HTML Preview is the right pane in the split view editor.
// It is effectively just a scrolling container for the HTML output from showdown
// It knows how to update itself, and that's pretty much it.
/*global Ghost, Showdown, Countable, _, $ */
(function () {
'use strict';
var HTMLPreview = function (markdown, uploadMgr) {
var converter = new Showdown.converter({extensions: ['ghostimagepreview', 'ghostgfm']}),
preview = document.getElementsByClassName('rendered-markdown')[0],
update;
// Update the preview
// Includes replacing all the HTML, intialising upload dropzones, and updating the counter
update = function () {
preview.innerHTML = converter.makeHtml(markdown.value());
uploadMgr.enable();
Countable.once(preview, function (counter) {
$('.entry-word-count').text($.pluralize(counter.words, 'word'));
$('.entry-character-count').text($.pluralize(counter.characters, 'character'));
$('.entry-paragraph-count').text($.pluralize(counter.paragraphs, 'paragraph'));
});
};
// Public API
_.extend(this, {
scrollViewPort: function () {
return $('.entry-preview-content');
},
scrollContent: function () {
return $('.rendered-markdown');
},
update: update
});
};
Ghost.Editor = Ghost.Editor || {};
Ghost.Editor.HTMLPreview = HTMLPreview;
} ());

View File

@ -0,0 +1,79 @@
// # Ghost Editor
//
// Ghost Editor contains a set of modules which make up the editor component
// It manages the left and right panes, and all of the communication between them
// Including scrolling,
/*global document, $, _, Ghost */
(function () {
'use strict';
var Editor = function () {
var self = this,
$document = $(document),
// Create all the needed editor components, passing them what they need to function
markdown = new Ghost.Editor.MarkdownEditor(),
uploadMgr = new Ghost.Editor.UploadManager(markdown),
preview = new Ghost.Editor.HTMLPreview(markdown, uploadMgr),
scrollHandler = new Ghost.Editor.ScrollHandler(markdown, preview),
unloadDirtyMessage,
handleChange,
handleDrag;
unloadDirtyMessage = function () {
return '==============================\n\n' +
'Hey there! It looks like you\'re in the middle of writing' +
' something and you haven\'t saved all of your content.' +
'\n\nSave before you go!\n\n' +
'==============================';
};
handleChange = function () {
self.setDirty(true);
preview.update();
};
handleDrag = function (e) {
e.preventDefault();
};
// Public API
_.extend(this, {
enable: function () {
// Listen for changes
$document.on('markdownEditorChange', handleChange);
// enable editing and scrolling
markdown.enable();
scrollHandler.enable();
},
disable: function () {
// Don't listen for changes
$document.off('markdownEditorChange', handleChange);
// disable editing and scrolling
markdown.disable();
scrollHandler.disable();
},
// Get the markdown value from the editor for saving
// Upload manager makes sure the upload markers are removed beforehand
value: function () {
return uploadMgr.value();
},
setDirty: function (dirty) {
window.onbeforeunload = dirty ? unloadDirtyMessage : null;
}
});
// Initialise
$document.on('drop dragover', handleDrag);
preview.update();
this.enable();
};
Ghost.Editor = Ghost.Editor || {};
Ghost.Editor.Main = Editor;
}());

View File

@ -0,0 +1,95 @@
// # Ghost Editor Markdown Editor
//
// Markdown Editor is a light wrapper around CodeMirror
/*global Ghost, CodeMirror, shortcut, _, $ */
(function () {
'use strict';
var MarkdownShortcuts,
MarkdownEditor;
MarkdownShortcuts = [
{'key': 'Ctrl+B', 'style': 'bold'},
{'key': 'Meta+B', 'style': 'bold'},
{'key': 'Ctrl+I', 'style': 'italic'},
{'key': 'Meta+I', 'style': 'italic'},
{'key': 'Ctrl+Alt+U', 'style': 'strike'},
{'key': 'Ctrl+Shift+K', 'style': 'code'},
{'key': 'Meta+K', 'style': 'code'},
{'key': 'Ctrl+Alt+1', 'style': 'h1'},
{'key': 'Ctrl+Alt+2', 'style': 'h2'},
{'key': 'Ctrl+Alt+3', 'style': 'h3'},
{'key': 'Ctrl+Alt+4', 'style': 'h4'},
{'key': 'Ctrl+Alt+5', 'style': 'h5'},
{'key': 'Ctrl+Alt+6', 'style': 'h6'},
{'key': 'Ctrl+Shift+L', 'style': 'link'},
{'key': 'Ctrl+Shift+I', 'style': 'image'},
{'key': 'Ctrl+Q', 'style': 'blockquote'},
{'key': 'Ctrl+Shift+1', 'style': 'currentDate'},
{'key': 'Ctrl+U', 'style': 'uppercase'},
{'key': 'Ctrl+Shift+U', 'style': 'lowercase'},
{'key': 'Ctrl+Alt+Shift+U', 'style': 'titlecase'},
{'key': 'Ctrl+Alt+W', 'style': 'selectword'},
{'key': 'Ctrl+L', 'style': 'list'},
{'key': 'Ctrl+Alt+C', 'style': 'copyHTML'},
{'key': 'Meta+Alt+C', 'style': 'copyHTML'},
{'key': 'Meta+Enter', 'style': 'newLine'},
{'key': 'Ctrl+Enter', 'style': 'newLine'}
];
MarkdownEditor = function () {
var codemirror = CodeMirror.fromTextArea(document.getElementById('entry-markdown'), {
mode: 'gfm',
tabMode: 'indent',
tabindex: '2',
cursorScrollMargin: 10,
lineWrapping: true,
dragDrop: false,
extraKeys: {
Home: 'goLineLeft',
End: 'goLineRight'
}
});
// Markdown shortcuts for the editor
_.each(MarkdownShortcuts, function (combo) {
shortcut.add(combo.key, function () {
return codemirror.addMarkdown({style: combo.style});
});
});
// Public API
_.extend(this, {
codemirror: codemirror,
scrollViewPort: function () {
return $('.CodeMirror-scroll');
},
scrollContent: function () {
return $('.CodeMirror-sizer');
},
enable: function () {
codemirror.setOption('readOnly', false);
codemirror.on('change', function () {
$(document).trigger('markdownEditorChange');
});
},
disable: function () {
codemirror.setOption('readOnly', 'nocursor');
codemirror.off('change', function () {
$(document).trigger('markdownEditorChange');
});
},
isCursorAtEnd: function () {
return codemirror.getCursor('end').line > codemirror.lineCount() - 5;
},
value: function () {
return codemirror.getValue();
}
});
};
Ghost.Editor = Ghost.Editor || {};
Ghost.Editor.MarkdownEditor = MarkdownEditor;
} ());

View File

@ -0,0 +1,154 @@
// # Ghost Editor Marker Manager
//
// MarkerManager looks after the array of markers which are attached to image markdown in the editor.
//
// Marker Manager is told by the Upload Manager to add a marker to a line.
// A marker takes the form of a 'magic id' which looks like:
// {<1>}
// It is appended to the start of the given line, and then defined as a CodeMirror 'TextMarker' widget which is
// subsequently added to an array of markers to keep track of all markers in the editor.
// The TextMarker is also set to 'collapsed' mode which means it does not show up in the display.
// Currently, the markers can be seen if you copy and paste your content out of Ghost into a text editor.
// The markers are stripped on save so should not appear in the DB
/*global _, Ghost */
(function () {
'use strict';
var imageMarkdownRegex = /^(?:\{<(.*?)>\})?!(?:\[([^\n\]]*)\])(?:\(([^\n\]]*)\))?$/gim,
markerRegex = /\{<([\w\W]*?)>\}/,
MarkerManager;
MarkerManager = function (editor) {
var markers = {},
uploadPrefix = 'image_upload',
uploadId = 1,
addMarker,
removeMarker,
markerRegexForId,
stripMarkerFromLine,
findAndStripMarker,
checkMarkers,
initMarkers;
// the regex
markerRegexForId = function (id) {
id = id.replace('image_upload_', '');
return new RegExp('\\{<' + id + '>\\}', 'gmi');
};
// Add a marker to the given line
// Params:
// line - CodeMirror LineHandle
// ln - line number
addMarker = function (line, ln) {
var marker,
magicId = '{<' + uploadId + '>}',
newText = magicId + line.text;
editor.replaceRange(
newText,
{line: ln, ch: 0},
{line: ln, ch: newText.length}
);
marker = editor.markText(
{line: ln, ch: 0},
{line: ln, ch: (magicId.length)},
{collapsed: true}
);
markers[uploadPrefix + '_' + uploadId] = marker;
uploadId += 1;
};
// Remove a marker
// Will be passed a LineHandle if we already know which line the marker is on
removeMarker = function (id, marker, line) {
delete markers[id];
marker.clear();
if (line) {
stripMarkerFromLine(line);
} else {
findAndStripMarker(id);
}
};
// Removes the marker on the given line if there is one
stripMarkerFromLine = function (line) {
var markerText = line.text.match(markerRegex),
ln = editor.getLineNumber(line);
if (markerText) {
editor.replaceRange(
'',
{line: ln, ch: markerText.index},
{line: ln, ch: markerText.index + markerText[0].length}
);
}
};
// Find a marker in the editor by id & remove it
// Goes line by line to find the marker by it's text if we've lost track of the TextMarker
findAndStripMarker = function (id) {
editor.eachLine(function (line) {
var markerText = markerRegexForId(id).exec(line.text),
ln;
if (markerText) {
ln = editor.getLineNumber(line);
editor.replaceRange(
'',
{line: ln, ch: markerText.index},
{line: ln, ch: markerText.index + markerText[0].length}
);
}
});
};
// Check each marker to see if it is still present in the editor and if it still corresponds to image markdown
// If it is no longer a valid image, remove it
checkMarkers = function () {
_.each(markers, function (marker, id) {
var line;
marker = markers[id];
if (marker.find()) {
line = editor.getLineHandle(marker.find().from.line);
if (!line.text.match(imageMarkdownRegex)) {
removeMarker(id, marker, line);
}
} else {
removeMarker(id, marker);
}
});
};
// Add markers to the line if it needs one
initMarkers = function (line) {
var isImage = line.text.match(imageMarkdownRegex),
hasMarker = line.text.match(markerRegex);
if (isImage && !hasMarker) {
addMarker(line, editor.getLineNumber(line));
}
};
// Initialise
editor.eachLine(initMarkers);
// Public API
_.extend(this, {
markers: markers,
checkMarkers: checkMarkers,
addMarker: addMarker,
stripMarkerFromLine: stripMarkerFromLine,
getMarkerRegexForId: markerRegexForId
});
};
Ghost.Editor = Ghost.Editor || {};
Ghost.Editor.MarkerManager = MarkerManager;
}());

View File

@ -0,0 +1,112 @@
// Taken from js-bin with thanks to Remy Sharp
// yeah, nasty, but it allows me to switch from a RTF to plain text if we're running a iOS
/*global Ghost, $, _, DocumentTouch, CodeMirror*/
(function () {
Ghost.touchEditor = false;
var noop = function () {},
hasTouchScreen,
smallScreen,
TouchEditor,
_oldCM,
key;
// Taken from "Responsive design & the Guardian" with thanks to Matt Andrews
// Added !window._phantom so that the functional tests run as though this is not a touch screen.
// In future we can do something more advanced here for testing both touch and non touch
hasTouchScreen = function () {
return !window._phantom &&
(
('ontouchstart' in window) ||
(window.DocumentTouch && document instanceof DocumentTouch)
);
};
smallScreen = function () {
if (window.matchMedia('(max-width: 1000px)').matches) {
return true;
}
return false;
};
if (hasTouchScreen()) {
$('body').addClass('touch-editor');
Ghost.touchEditor = true;
TouchEditor = function (el, options) {
/*jshint unused:false*/
this.textarea = el;
this.win = { document : this.textarea };
this.ready = true;
this.wrapping = document.createElement('div');
var textareaParent = this.textarea.parentNode;
this.wrapping.appendChild(this.textarea);
textareaParent.appendChild(this.wrapping);
this.textarea.style.opacity = 1;
$(this.textarea).blur(_.throttle(function () {
$(document).trigger('markdownEditorChange', { panelId: el.id });
}, 200));
if (!smallScreen()) {
$(this.textarea).on('change', _.throttle(function () {
$(document).trigger('markdownEditorChange', { panelId: el.id });
}, 200));
}
};
TouchEditor.prototype = {
setOption: function (type, handler) {
if (type === 'onChange') {
$(this.textarea).change(handler);
}
},
eachLine: function () {
return [];
},
getValue: function () {
return this.textarea.value;
},
setValue: function (code) {
this.textarea.value = code;
},
focus: noop,
getCursor: function () {
return { line: 0, ch: 0 };
},
setCursor: noop,
currentLine: function () {
return 0;
},
cursorPosition: function () {
return { character: 0 };
},
addMarkdown: noop,
nthLine: noop,
refresh: noop,
selectLines: noop,
on: noop
};
_oldCM = CodeMirror;
// CodeMirror = noop;
for (key in _oldCM) {
if (_oldCM.hasOwnProperty(key)) {
CodeMirror[key] = noop;
}
}
CodeMirror.fromTextArea = function (el, options) {
return new TouchEditor(el, options);
};
CodeMirror.keyMap = { basic: {} };
}
}());

View File

@ -0,0 +1,47 @@
// # Ghost Editor Scroll Handler
//
// Scroll Handler does the (currently very simple / naive) job of syncing the right pane with the left pane
// as the right pane scrolls
/*global Ghost, _ */
(function () {
'use strict';
var ScrollHandler = function (markdown, preview) {
var $markdownViewPort = markdown.scrollViewPort(),
$previewViewPort = preview.scrollViewPort(),
$markdownContent = markdown.scrollContent(),
$previewContent = preview.scrollContent(),
syncScroll;
syncScroll = _.throttle(function () {
// calc position
var markdownHeight = $markdownContent.height() - $markdownViewPort.height(),
previewHeight = $previewContent.height() - $previewViewPort.height(),
ratio = previewHeight / markdownHeight,
previewPosition = $markdownViewPort.scrollTop() * ratio;
if (markdown.isCursorAtEnd()) {
previewPosition = previewHeight + 30;
}
// apply new scroll
$previewViewPort.scrollTop(previewPosition);
}, 10);
_.extend(this, {
enable: function () { // Handle Scroll Events
$markdownViewPort.on('scroll', syncScroll);
$markdownViewPort.scrollClass({target: '.entry-markdown', offset: 10});
$previewViewPort.scrollClass({target: '.entry-preview', offset: 10});
},
disable: function () {
$markdownViewPort.off('scroll', syncScroll);
}
});
};
Ghost.Editor = Ghost.Editor || {};
Ghost.Editor.ScrollHandler = ScrollHandler;
} ());

View File

@ -0,0 +1,153 @@
// # Ghost Editor Upload Manager
//
// UploadManager ensures that markdown gets updated when images get uploaded via the Preview.
//
// The Ghost Editor has a particularly tricky problem to solve, in that it is possible to upload an image by
// interacting with the preview. The process of uploading an image is handled by uploader.js, but there is still
// a lot of work needed to ensure that uploaded files end up in the right place - that is that the image
// path gets added to the correct piece of markdown in the editor.
//
// To solve this, Ghost adds a unique 'marker' to each piece of markdown which represents an image:
// More detail about how the markers work can be find in markerManager.js
//
// UploadManager handles changes in the editor, looking for text which matches image markdown, and telling the marker
// manager to add a marker. It also checks changed lines to see if they have a marker but are no longer an image.
//
// UploadManager's most important job is handling uploads such that when a successful upload completes, the correct
// piece of image markdown is updated with the path.
// This is done in part by ghostImagePreview.js, which takes the marker from the markdown and uses it to create an ID
// on the dropzone. When an upload completes successfully from uploader.js, the event thrown contains reference to the
// dropzone, from which uploadManager can pull the ID & then get the right marker from the Marker Manager.
//
// Without a doubt, the separation of concerns between the uploadManager, and the markerManager could be vastly
// improved
/*global $, _, Ghost */
(function () {
'use strict';
var imageMarkdownRegex = /^(?:\{<(.*?)>\})?!(?:\[([^\n\]]*)\])(?:\(([^\n\]]*)\))?$/gim,
markerRegex = /\{<([\w\W]*?)>\}/,
UploadManager;
UploadManager = function (markdown) {
var editor = markdown.codemirror,
markerMgr = new Ghost.Editor.MarkerManager(editor),
findLine,
checkLine,
value,
handleUpload,
handleChange;
// Find the line with the marker which matches
findLine = function (result_id) {
// try to find the right line to replace
if (markerMgr.markers.hasOwnProperty(result_id) && markerMgr.markers[result_id].find()) {
return editor.getLineHandle(markerMgr.markers[result_id].find().from.line);
}
return false;
};
// Check the given line to see if it has an image, and if it correctly has a marker
// In the special case of lines which were just pasted in, any markers are removed to prevent duplication
checkLine = function (ln, mode) {
var line = editor.getLineHandle(ln),
isImage = line.text.match(imageMarkdownRegex),
hasMarker;
// We care if it is an image
if (isImage) {
hasMarker = line.text.match(markerRegex);
if (hasMarker && (mode === 'paste' || mode === 'undo')) {
// this could be a duplicate, and won't be a real marker
markerMgr.stripMarkerFromLine(line);
}
if (!hasMarker) {
markerMgr.addMarker(line, ln);
}
}
// TODO: hasMarker but no image?
};
// Get the markdown with all the markers stripped
value = function () {
var value = editor.getValue();
_.each(markerMgr.markers, function (marker, id) {
/*jshint unused:false*/
value = value.replace(markerMgr.getMarkerRegexForId(id), '');
});
return value;
};
// Match the uploaded file to a line in the editor, and update that line with a path reference
// ensuring that everything ends up in the correct place and format.
handleUpload = function (e, result_src) {
var line = findLine($(e.currentTarget).attr('id')),
lineNumber = editor.getLineNumber(line),
match = line.text.match(/\([^\n]*\)?/),
replacement = '(http://)';
if (match) {
// simple case, we have the parenthesis
editor.setSelection(
{line: lineNumber, ch: match.index + 1},
{line: lineNumber, ch: match.index + match[0].length - 1}
);
} else {
match = line.text.match(/\]/);
if (match) {
editor.replaceRange(
replacement,
{line: lineNumber, ch: match.index + 1},
{line: lineNumber, ch: match.index + 1}
);
editor.setSelection(
{line: lineNumber, ch: match.index + 2},
{line: lineNumber, ch: match.index + replacement.length }
);
}
}
editor.replaceSelection(result_src);
};
// Change events from CodeMirror tell us which lines have changed.
// Each changed line is then checked to see if a marker needs to be added or removed
handleChange = function (cm, changeObj) {
/*jshint unused:false*/
var linesChanged = _.range(changeObj.from.line, changeObj.from.line + changeObj.text.length);
_.each(linesChanged, function (ln) {
checkLine(ln, changeObj.origin);
});
// Is this a line which may have had a marker on it?
markerMgr.checkMarkers();
};
// Public API
_.extend(this, {
value: value,
enable: function () {
var filestorage = $('#entry-markdown-content').data('filestorage');
$('.js-drop-zone').upload({editor: true, fileStorage: filestorage});
$('.js-drop-zone').on('uploadstart', markdown.off);
$('.js-drop-zone').on('uploadfailure', markdown.on);
$('.js-drop-zone').on('uploadsuccess', markdown.on);
$('.js-drop-zone').on('uploadsuccess', handleUpload);
},
disable: function () {
$('.js-drop-zone').off('uploadsuccess', handleUpload);
}
});
editor.on('change', handleChange);
};
Ghost.Editor = Ghost.Editor || {};
Ghost.Editor.UploadManager = UploadManager;
}());

View File

@ -130,13 +130,13 @@
CodeMirror.prototype.addMarkdown.options = {
style: null,
syntax: {
bold: "**$1**",
italic: "*$1*",
strike: "~~$1~~",
code: "`$1`",
link: "[$1](http://)",
image: "![$1](http://)",
blockquote: "> $1"
bold: '**$1**',
italic: '*$1*',
strike: '~~$1~~',
code: '`$1`',
link: '[$1](http://)',
image: '![$1](http://)',
blockquote: '> $1'
}
};

View File

@ -316,7 +316,7 @@
},
afterRender: function () {
this.$el.fadeIn(50);
$(".modal-background").fadeIn(10, function () {
$(".modal-background").show(10, function () {
$(this).addClass("in");
});
if (this.model.options.confirm) {

View File

@ -228,6 +228,7 @@
},
toggleFeatured: function (e) {
e.preventDefault();
var self = this,
featured = !self.model.get('featured'),
featuredEl = $(e.currentTarget),
@ -238,6 +239,7 @@
}, {
success : function () {
featuredEl.removeClass("featured unfeatured").addClass(featured ? "featured" : "unfeatured");
Ghost.notifications.clearEverything();
Ghost.notifications.addItem({
type: 'success',
message: "Post successfully marked as " + (featured ? "featured" : "not featured") + ".",

View File

@ -0,0 +1,255 @@
// The Save / Publish button
/*global $, _, Ghost, shortcut */
(function () {
'use strict';
// The Publish, Queue, Publish Now buttons
// ----------------------------------------
Ghost.View.EditorActionsWidget = Ghost.View.extend({
events: {
'click [data-set-status]': 'handleStatus',
'click .js-publish-button': 'handlePostButton'
},
statusMap: null,
createStatusMap: {
'draft': 'Save Draft',
'published': 'Publish Now'
},
updateStatusMap: {
'draft': 'Unpublish',
'published': 'Update Post'
},
//TODO: This has to be moved to the I18n localization file.
//This structure is supposed to be close to the i18n-localization which will be used soon.
messageMap: {
errors: {
post: {
published: {
'published': 'Your post could not be updated.',
'draft': 'Your post could not be saved as a draft.'
},
draft: {
'published': 'Your post could not be published.',
'draft': 'Your post could not be saved as a draft.'
}
}
},
success: {
post: {
published: {
'published': 'Your post has been updated.',
'draft': 'Your post has been saved as a draft.'
},
draft: {
'published': 'Your post has been published.',
'draft': 'Your post has been saved as a draft.'
}
}
}
},
initialize: function () {
var self = this;
// Toggle publish
shortcut.add('Ctrl+Alt+P', function () {
self.toggleStatus();
});
shortcut.add('Ctrl+S', function () {
self.updatePost();
});
shortcut.add('Meta+S', function () {
self.updatePost();
});
this.listenTo(this.model, 'change:status', this.render);
},
toggleStatus: function () {
var self = this,
keys = Object.keys(this.statusMap),
model = self.model,
prevStatus = model.get('status'),
currentIndex = keys.indexOf(prevStatus),
newIndex,
status;
newIndex = currentIndex + 1 > keys.length - 1 ? 0 : currentIndex + 1;
status = keys[newIndex];
this.setActiveStatus(keys[newIndex], this.statusMap[status], prevStatus);
this.savePost({
status: keys[newIndex]
}).then(function () {
self.reportSaveSuccess(status, prevStatus);
}, function (xhr) {
// Show a notification about the error
self.reportSaveError(xhr, model, status, prevStatus);
});
},
setActiveStatus: function (newStatus, displayText, currentStatus) {
var isPublishing = (newStatus === 'published' && currentStatus !== 'published'),
isUnpublishing = (newStatus === 'draft' && currentStatus === 'published'),
// Controls when background of button has the splitbutton-delete/button-delete classes applied
isImportantStatus = (isPublishing || isUnpublishing);
$('.js-publish-splitbutton')
.removeClass(isImportantStatus ? 'splitbutton-save' : 'splitbutton-delete')
.addClass(isImportantStatus ? 'splitbutton-delete' : 'splitbutton-save');
// Set the publish button's action and proper coloring
$('.js-publish-button')
.attr('data-status', newStatus)
.text(displayText)
.removeClass(isImportantStatus ? 'button-save' : 'button-delete')
.addClass(isImportantStatus ? 'button-delete' : 'button-save');
// Remove the animated popup arrow
$('.js-publish-splitbutton > a')
.removeClass('active');
// Set the active action in the popup
$('.js-publish-splitbutton .editor-options li')
.removeClass('active')
.filter(['li[data-set-status="', newStatus, '"]'].join(''))
.addClass('active');
},
handleStatus: function (e) {
if (e) { e.preventDefault(); }
var status = $(e.currentTarget).attr('data-set-status'),
currentStatus = this.model.get('status');
this.setActiveStatus(status, this.statusMap[status], currentStatus);
// Dismiss the popup menu
$('body').find('.overlay:visible').fadeOut();
},
handlePostButton: function (e) {
if (e) { e.preventDefault(); }
var status = $(e.currentTarget).attr('data-status');
this.updatePost(status);
},
updatePost: function (status) {
var self = this,
model = this.model,
prevStatus = model.get('status');
// Default to same status if not passed in
status = status || prevStatus;
model.trigger('willSave');
this.savePost({
status: status
}).then(function () {
self.reportSaveSuccess(status, prevStatus);
// Refresh publish button and all relevant controls with updated status.
self.render();
}, function (xhr) {
// Set the model status back to previous
model.set({ status: prevStatus });
// Set appropriate button status
self.setActiveStatus(status, self.statusMap[status], prevStatus);
// Show a notification about the error
self.reportSaveError(xhr, model, status, prevStatus);
});
},
savePost: function (data) {
var publishButton = $('.js-publish-button'),
saved,
enablePublish = function (deferred) {
deferred.always(function () {
publishButton.prop('disabled', false);
});
return deferred;
};
publishButton.prop('disabled', true);
_.each(this.model.blacklist, function (item) {
this.model.unset(item);
}, this);
saved = this.model.save(_.extend({
title: this.options.$title.val(),
markdown: this.options.editor.value()
}, data));
// TODO: Take this out if #2489 gets merged in Backbone. Or patch Backbone
// ourselves for more consistent promises.
if (saved) {
return enablePublish(saved);
}
return enablePublish($.Deferred().reject());
},
reportSaveSuccess: function (status, prevStatus) {
Ghost.notifications.clearEverything();
Ghost.notifications.addItem({
type: 'success',
message: this.messageMap.success.post[prevStatus][status],
status: 'passive'
});
this.options.editor.setDirty(false);
},
reportSaveError: function (response, model, status, prevStatus) {
var message = this.messageMap.errors.post[prevStatus][status];
if (response) {
// Get message from response
message += ' ' + Ghost.Views.Utils.getRequestErrorMessage(response);
} else if (model.validationError) {
// Grab a validation error
message += ' ' + model.validationError;
}
Ghost.notifications.clearEverything();
Ghost.notifications.addItem({
type: 'error',
message: message,
status: 'passive'
});
},
setStatusLabels: function (statusMap) {
_.each(statusMap, function (label, status) {
$('li[data-set-status="' + status + '"] > a').text(label);
});
},
render: function () {
var status = this.model.get('status');
// Assume that we're creating a new post
if (status !== 'published') {
this.statusMap = this.createStatusMap;
} else {
this.statusMap = this.updateStatusMap;
}
// Populate the publish menu with the appropriate verbiage
this.setStatusLabels(this.statusMap);
// Default the selected publish option to the current status of the post.
this.setActiveStatus(status, this.statusMap[status], status);
}
});
}());

View File

@ -45,7 +45,7 @@
if (tags) {
_.forEach(tags, function (tag) {
var $tag = $('<span class="tag" data-tag-id="' + tag.id + '">' + tag.name + '</span>');
var $tag = $('<span class="tag" data-tag-id="' + tag.id + '">' + _.escape(tag.name) + '</span>');
$tags.append($tag);
$("[data-tag-id=" + tag.id + "]")[0].scrollIntoView(true);
});
@ -106,8 +106,11 @@
styles = {
left: $target.position().left
},
maxSuggestions = 5, // Limit the suggestions number
regexTerm = searchTerm.replace(/(\s+)/g, "(<[^>]+>)*$1(<[^>]+>)*"),
// Limit the suggestions number
maxSuggestions = 5,
// Escape regex special characters
escapedTerm = searchTerm.replace(/[\-\/\\\^$*+?.()|\[\]{}]/g, '\\$&'),
regexTerm = escapedTerm.replace(/(\s+)/g, "(<[^>]+>)*$1(<[^>]+>)*"),
regexPattern = new RegExp("(" + regexTerm + ")", "i");
this.$suggestions.css(styles);
@ -121,10 +124,14 @@
var highlightedName,
suggestionHTML;
highlightedName = matchingTag.name.replace(regexPattern, "<mark>$1</mark>");
highlightedName = matchingTag.name.replace(regexPattern, function (match, p1) {
return "<mark>" + _.escape(p1) + "</mark>";
});
/*jslint regexp: true */ // - would like to remove this
highlightedName = highlightedName.replace(/(<mark>[^<>]*)((<[^>]+>)+)([^<>]*<\/mark>)/, "$1</mark>$2<mark>$4");
highlightedName = highlightedName.replace(/(<mark>[^<>]*)((<[^>]+>)+)([^<>]*<\/mark>)/, function (match, p1, p2, p3, p4) {
return _.escape(p1) + '</mark>' + _.escape(p2) + '<mark>' + _.escape(p4);
});
suggestionHTML = "<li data-tag-id='" + matchingTag.id + "' data-tag-name='" + _.escape(matchingTag.name) + "'><a href='#'>" + highlightedName + "</a></li>";
this.$suggestions.append(suggestionHTML);
}, this);
@ -185,7 +192,8 @@
searchTerm = $.trim($target.val()),
tag,
$selectedSuggestion,
isComma = ",".localeCompare(String.fromCharCode(e.keyCode || e.charCode)) === 0;
isComma = ",".localeCompare(String.fromCharCode(e.keyCode || e.charCode)) === 0,
hasAlreadyBeenAdded;
// use localeCompare in case of international keyboard layout
if ((e.keyCode === this.keys.ENTER || isComma) && searchTerm) {
@ -194,16 +202,19 @@
$selectedSuggestion = this.$suggestions.children(".selected");
if (this.$suggestions.is(":visible") && $selectedSuggestion.length !== 0) {
if ($('.tag:containsExact("' + _.unescape($selectedSuggestion.data('tag-name')) + '")').length === 0) {
tag = {id: $selectedSuggestion.data('tag-id'), name: _.unescape($selectedSuggestion.data('tag-name'))};
tag = {id: $selectedSuggestion.data('tag-id'), name: _.unescape($selectedSuggestion.data('tag-name'))};
hasAlreadyBeenAdded = this.hasTagBeenAdded(tag.name);
if (!hasAlreadyBeenAdded) {
this.addTag(tag);
}
} else {
if (isComma) {
// Remove comma from string if comma is used to submit.
searchTerm = searchTerm.replace(/,/g, "");
} // Remove comma from string if comma is used to submit.
if ($('.tag:containsExact("' + searchTerm + '")').length === 0) {
}
hasAlreadyBeenAdded = this.hasTagBeenAdded(searchTerm);
if (!hasAlreadyBeenAdded) {
this.addTag({id: null, name: searchTerm});
}
}
@ -216,13 +227,9 @@
completeCurrentTag: function () {
var $target = this.$('.tag-input'),
tagName = $target.val(),
usedTagNames,
hasAlreadyBeenAdded;
usedTagNames = _.map(this.model.get('tags'), function (tag) {
return tag.name.toUpperCase();
});
hasAlreadyBeenAdded = usedTagNames.indexOf(tagName.toUpperCase()) !== -1;
hasAlreadyBeenAdded = this.hasTagBeenAdded(tagName);
if (tagName.length > 0 && !hasAlreadyBeenAdded) {
this.addTag({id: null, name: tagName});
@ -267,9 +274,8 @@
tagNameMatches = tag.name.toUpperCase().indexOf(searchTerm) !== -1;
hasAlreadyBeenAdded = _.some(self.model.get('tags'), function (usedTag) {
return tag.name.toUpperCase() === usedTag.name.toUpperCase();
});
hasAlreadyBeenAdded = self.hasTagBeenAdded(tag.name);
return tagNameMatches && !hasAlreadyBeenAdded;
});
@ -277,7 +283,7 @@
},
addTag: function (tag) {
var $tag = $('<span class="tag" data-tag-id="' + tag.id + '">' + tag.name + '</span>');
var $tag = $('<span class="tag" data-tag-id="' + tag.id + '">' + _.escape(tag.name) + '</span>');
this.$('.tags').append($tag);
$(".tag").last()[0].scrollIntoView(true);
window.scrollTo(0, 1);
@ -285,6 +291,12 @@
this.$('.tag-input').val('').focus();
this.$suggestions.hide();
},
hasTagBeenAdded: function (tagName) {
return _.some(this.model.get('tags'), function (usedTag) {
return tagName.toUpperCase() === usedTag.name.toUpperCase();
});
}
});

View File

@ -1,45 +1,10 @@
// # Article Editor
/*global window, document, setTimeout, navigator, $, _, Backbone, Ghost, Showdown, CodeMirror, shortcut, Countable */
/*global document, setTimeout, navigator, $, Backbone, Ghost, shortcut */
(function () {
"use strict";
'use strict';
/*jslint regexp: true, bitwise: true */
var PublishBar,
ActionsWidget,
UploadManager,
MarkerManager,
MarkdownShortcuts = [
{'key': 'Ctrl+B', 'style': 'bold'},
{'key': 'Meta+B', 'style': 'bold'},
{'key': 'Ctrl+I', 'style': 'italic'},
{'key': 'Meta+I', 'style': 'italic'},
{'key': 'Ctrl+Alt+U', 'style': 'strike'},
{'key': 'Ctrl+Shift+K', 'style': 'code'},
{'key': 'Meta+K', 'style': 'code'},
{'key': 'Ctrl+Alt+1', 'style': 'h1'},
{'key': 'Ctrl+Alt+2', 'style': 'h2'},
{'key': 'Ctrl+Alt+3', 'style': 'h3'},
{'key': 'Ctrl+Alt+4', 'style': 'h4'},
{'key': 'Ctrl+Alt+5', 'style': 'h5'},
{'key': 'Ctrl+Alt+6', 'style': 'h6'},
{'key': 'Ctrl+Shift+L', 'style': 'link'},
{'key': 'Ctrl+Shift+I', 'style': 'image'},
{'key': 'Ctrl+Q', 'style': 'blockquote'},
{'key': 'Ctrl+Shift+1', 'style': 'currentDate'},
{'key': 'Ctrl+U', 'style': 'uppercase'},
{'key': 'Ctrl+Shift+U', 'style': 'lowercase'},
{'key': 'Ctrl+Alt+Shift+U', 'style': 'titlecase'},
{'key': 'Ctrl+Alt+W', 'style': 'selectword'},
{'key': 'Ctrl+L', 'style': 'list'},
{'key': 'Ctrl+Alt+C', 'style': 'copyHTML'},
{'key': 'Meta+Alt+C', 'style': 'copyHTML'},
{'key': 'Meta+Enter', 'style': 'newLine'},
{'key': 'Ctrl+Enter', 'style': 'newLine'}
],
imageMarkdownRegex = /^(?:\{<(.*?)>\})?!(?:\[([^\n\]]*)\])(?:\(([^\n\]]*)\))?$/gim,
markerRegex = /\{<([\w\W]*?)>\}/;
/*jslint regexp: false, bitwise: false */
var PublishBar;
// The publish bar associated with a post, which has the TagWidget and
// Save button and options and such.
@ -47,364 +12,88 @@
PublishBar = Ghost.View.extend({
initialize: function () {
this.addSubview(new Ghost.View.EditorTagWidget({el: this.$('#entry-tags'), model: this.model})).render();
this.addSubview(new ActionsWidget({el: this.$('#entry-actions'), model: this.model})).render();
this.addSubview(new Ghost.View.PostSettings({el: $('#entry-controls'), model: this.model})).render();
this.addSubview(new Ghost.View.EditorTagWidget(
{el: this.$('#entry-tags'), model: this.model}
)).render();
this.addSubview(new Ghost.View.PostSettings(
{el: $('#entry-controls'), model: this.model}
)).render();
// Pass the Actions widget references to the title and editor so that it can get
// the values that need to be saved
this.addSubview(new Ghost.View.EditorActionsWidget(
{
el: this.$('#entry-actions'),
model: this.model,
$title: this.options.$title,
editor: this.options.editor
}
)).render();
},
render: function () { return this; }
});
// The Publish, Queue, Publish Now buttons
// ----------------------------------------
ActionsWidget = Ghost.View.extend({
events: {
'click [data-set-status]': 'handleStatus',
'click .js-publish-button': 'handlePostButton'
},
statusMap: null,
createStatusMap: {
'draft': 'Save Draft',
'published': 'Publish Now'
},
updateStatusMap: {
'draft': 'Unpublish',
'published': 'Update Post'
},
//TODO: This has to be moved to the I18n localization file.
//This structure is supposed to be close to the i18n-localization which will be used soon.
messageMap: {
errors: {
post: {
published: {
'published': 'Your post could not be updated.',
'draft': 'Your post could not be saved as a draft.'
},
draft: {
'published': 'Your post could not be published.',
'draft': 'Your post could not be saved as a draft.'
}
}
},
success: {
post: {
published: {
'published': 'Your post has been updated.',
'draft': 'Your post has been saved as a draft.'
},
draft: {
'published': 'Your post has been published.',
'draft': 'Your post has been saved as a draft.'
}
}
}
},
initialize: function () {
var self = this;
// Toggle publish
shortcut.add("Ctrl+Alt+P", function () {
self.toggleStatus();
});
shortcut.add("Ctrl+S", function () {
self.updatePost();
});
shortcut.add("Meta+S", function () {
self.updatePost();
});
this.listenTo(this.model, 'change:status', this.render);
},
toggleStatus: function () {
var self = this,
keys = Object.keys(this.statusMap),
model = self.model,
prevStatus = model.get('status'),
currentIndex = keys.indexOf(prevStatus),
newIndex,
status;
newIndex = currentIndex + 1 > keys.length - 1 ? 0 : currentIndex + 1;
status = keys[newIndex];
this.setActiveStatus(keys[newIndex], this.statusMap[status], prevStatus);
this.savePost({
status: keys[newIndex]
}).then(function () {
self.reportSaveSuccess(status, prevStatus);
}, function (xhr) {
// Show a notification about the error
self.reportSaveError(xhr, model, status, prevStatus);
});
},
setActiveStatus: function (newStatus, displayText, currentStatus) {
var isPublishing = (newStatus === 'published' && currentStatus !== 'published'),
isUnpublishing = (newStatus === 'draft' && currentStatus === 'published'),
// Controls when background of button has the splitbutton-delete/button-delete classes applied
isImportantStatus = (isPublishing || isUnpublishing);
$('.js-publish-splitbutton')
.removeClass(isImportantStatus ? 'splitbutton-save' : 'splitbutton-delete')
.addClass(isImportantStatus ? 'splitbutton-delete' : 'splitbutton-save');
// Set the publish button's action and proper coloring
$('.js-publish-button')
.attr('data-status', newStatus)
.text(displayText)
.removeClass(isImportantStatus ? 'button-save' : 'button-delete')
.addClass(isImportantStatus ? 'button-delete' : 'button-save');
// Remove the animated popup arrow
$('.js-publish-splitbutton > a')
.removeClass('active');
// Set the active action in the popup
$('.js-publish-splitbutton .editor-options li')
.removeClass('active')
.filter(['li[data-set-status="', newStatus, '"]'].join(''))
.addClass('active');
},
handleStatus: function (e) {
if (e) { e.preventDefault(); }
var status = $(e.currentTarget).attr('data-set-status'),
currentStatus = this.model.get('status');
this.setActiveStatus(status, this.statusMap[status], currentStatus);
// Dismiss the popup menu
$('body').find('.overlay:visible').fadeOut();
},
handlePostButton: function (e) {
if (e) { e.preventDefault(); }
var status = $(e.currentTarget).attr('data-status');
this.updatePost(status);
},
updatePost: function (status) {
var self = this,
model = this.model,
prevStatus = model.get('status');
// Default to same status if not passed in
status = status || prevStatus;
model.trigger('willSave');
this.savePost({
status: status
}).then(function () {
self.reportSaveSuccess(status, prevStatus);
// Refresh publish button and all relevant controls with updated status.
self.render();
}, function (xhr) {
// Set the model status back to previous
model.set({ status: prevStatus });
// Set appropriate button status
self.setActiveStatus(status, self.statusMap[status], prevStatus);
// Show a notification about the error
self.reportSaveError(xhr, model, status, prevStatus);
});
},
savePost: function (data) {
var publishButton = $('.js-publish-button'),
saved,
enablePublish = function (deferred) {
deferred.always(function () {
publishButton.prop('disabled', false);
});
return deferred;
};
publishButton.prop('disabled', true);
_.each(this.model.blacklist, function (item) {
this.model.unset(item);
}, this);
saved = this.model.save(_.extend({
title: $('#entry-title').val(),
markdown: Ghost.currentView.getEditorValue()
}, data));
// TODO: Take this out if #2489 gets merged in Backbone. Or patch Backbone
// ourselves for more consistent promises.
if (saved) {
return enablePublish(saved);
}
return enablePublish($.Deferred().reject());
},
reportSaveSuccess: function (status, prevStatus) {
Ghost.notifications.clearEverything();
Ghost.notifications.addItem({
type: 'success',
message: this.messageMap.success.post[prevStatus][status],
status: 'passive'
});
Ghost.currentView.setEditorDirty(false);
},
reportSaveError: function (response, model, status, prevStatus) {
var message = this.messageMap.errors.post[prevStatus][status];
if (response) {
// Get message from response
message += " " + Ghost.Views.Utils.getRequestErrorMessage(response);
} else if (model.validationError) {
// Grab a validation error
message += " " + model.validationError;
}
Ghost.notifications.clearEverything();
Ghost.notifications.addItem({
type: 'error',
message: message,
status: 'passive'
});
},
setStatusLabels: function (statusMap) {
_.each(statusMap, function (label, status) {
$('li[data-set-status="' + status + '"] > a').text(label);
});
},
render: function () {
var status = this.model.get('status');
// Assume that we're creating a new post
if (status !== 'published') {
this.statusMap = this.createStatusMap;
} else {
this.statusMap = this.updateStatusMap;
}
// Populate the publish menu with the appropriate verbiage
this.setStatusLabels(this.statusMap);
// Default the selected publish option to the current status of the post.
this.setActiveStatus(status, this.statusMap[status], status);
}
});
// The entire /editor page's route
// ----------------------------------------
Ghost.Views.Editor = Ghost.View.extend({
initialize: function () {
var self = this;
// Add the container view for the Publish Bar
this.addSubview(new PublishBar({el: "#publish-bar", model: this.model})).render();
this.$('#entry-title').val(this.model.get('title')).focus();
this.$('#entry-markdown').text(this.model.get('markdown'));
this.listenTo(this.model, 'change:title', this.renderTitle);
this.listenTo(this.model, 'change:id', function (m) {
// This is a special case for browsers which fire an unload event when using navigate. The id change
// happens before the save success and can cause the unload alert to appear incorrectly on first save
// The id only changes in the event that the save has been successful, so this workaround is safes
self.setEditorDirty(false);
Backbone.history.navigate('/editor/' + m.id + '/');
});
this.initMarkdown();
this.renderPreview();
$('.entry-content header, .entry-preview header').on('click', function () {
$('.entry-content, .entry-preview').removeClass('active');
$(this).closest('section').addClass('active');
});
$('.entry-title .icon-fullscreen').on('click', function (e) {
e.preventDefault();
$('body').toggleClass('fullscreen');
});
this.$('.CodeMirror-scroll').on('scroll', this.syncScroll);
this.$('.CodeMirror-scroll').scrollClass({target: '.entry-markdown', offset: 10});
this.$('.entry-preview-content').scrollClass({target: '.entry-preview', offset: 10});
// Zen writing mode shortcut
shortcut.add("Alt+Shift+Z", function () {
$('body').toggleClass('zen');
});
$('.entry-markdown header, .entry-preview header').click(function (e) {
$('.entry-markdown, .entry-preview').removeClass('active');
$(e.target).closest('section').addClass('active');
});
// Deactivate default drag/drop action
$(document).bind('drop dragover', function (e) {
e.preventDefault();
});
},
events: {
'click .markdown-help': 'showHelp',
'blur #entry-title': 'trimTitle',
'orientationchange': 'orientationChange'
},
syncScroll: _.throttle(function (e) {
var $codeViewport = $(e.target),
$previewViewport = $('.entry-preview-content'),
$codeContent = $('.CodeMirror-sizer'),
$previewContent = $('.rendered-markdown'),
initialize: function () {
this.$title = this.$('#entry-title');
this.$editor = this.$('#entry-markdown');
// calc position
codeHeight = $codeContent.height() - $codeViewport.height(),
previewHeight = $previewContent.height() - $previewViewport.height(),
ratio = previewHeight / codeHeight,
previewPostition = $codeViewport.scrollTop() * ratio;
this.$title.val(this.model.get('title')).focus();
this.$editor.text(this.model.get('markdown'));
// apply new scroll
$previewViewport.scrollTop(previewPostition);
}, 10),
// Create a new editor
this.editor = new Ghost.Editor.Main();
showHelp: function () {
this.addSubview(new Ghost.Views.Modal({
model: {
options: {
close: true,
style: ["wide"],
animation: 'fade'
},
content: {
template: 'markdown',
title: 'Markdown Help'
}
}
}));
// Add the container view for the Publish Bar
// Passing reference to the title and editor
this.addSubview(new PublishBar(
{el: '#publish-bar', model: this.model, $title: this.$title, editor: this.editor}
)).render();
this.listenTo(this.model, 'change:title', this.renderTitle);
this.listenTo(this.model, 'change:id', this.handleIdChange);
this.bindShortcuts();
$('.entry-markdown header, .entry-preview header').on('click', function (e) {
$('.entry-markdown, .entry-preview').removeClass('active');
$(e.currentTarget).closest('section').addClass('active');
});
},
bindShortcuts: function () {
var self = this;
// Zen writing mode shortcut - full editor view
shortcut.add('Alt+Shift+Z', function () {
$('body').toggleClass('zen');
});
// HTML copy & paste
shortcut.add('Ctrl+Alt+C', function () {
self.showHTML();
});
},
trimTitle: function () {
var $title = $('#entry-title'),
rawTitle = $title.val(),
var rawTitle = this.$title.val(),
trimmedTitle = $.trim(rawTitle);
if (rawTitle !== trimmedTitle) {
$title.val(trimmedTitle);
this.$title.val(trimmedTitle);
}
// Trigger title change for post-settings.js
@ -412,7 +101,15 @@
},
renderTitle: function () {
this.$('#entry-title').val(this.model.get('title'));
this.$title.val(this.model.get('title'));
},
handleIdChange: function (m) {
// This is a special case for browsers which fire an unload event when using navigate. The id change
// happens before the save success and can cause the unload alert to appear incorrectly on first save
// The id only changes in the event that the save has been successful, so this workaround is safes
this.editor.setDirty(false);
Backbone.history.navigate('/editor/' + m.id + '/');
},
// This is a hack to remove iOS6 white space on orientation change bug
@ -427,307 +124,35 @@
}
},
// This updates the editor preview panel.
// Currently gets called on every key press.
// Also trigger word count update
renderPreview: function () {
var self = this,
preview = document.getElementsByClassName('rendered-markdown')[0];
preview.innerHTML = this.converter.makeHtml(this.editor.getValue());
this.initUploads();
Countable.once(preview, function (counter) {
self.$('.entry-word-count').text($.pluralize(counter.words, 'word'));
self.$('.entry-character-count').text($.pluralize(counter.characters, 'character'));
self.$('.entry-paragraph-count').text($.pluralize(counter.paragraphs, 'paragraph'));
});
},
// Markdown converter & markdown shortcut initialization.
initMarkdown: function () {
var self = this;
this.converter = new Showdown.converter({extensions: ['typography', 'ghostdown', 'github']});
this.editor = CodeMirror.fromTextArea(document.getElementById('entry-markdown'), {
mode: 'gfm',
tabMode: 'indent',
tabindex: "2",
lineWrapping: true,
dragDrop: false,
extraKeys: {
Home: "goLineLeft",
End: "goLineRight"
}
});
this.uploadMgr = new UploadManager(this.editor);
// Inject modal for HTML to be viewed in
shortcut.add("Ctrl+Alt+C", function () {
self.showHTML();
});
shortcut.add("Ctrl+Alt+C", function () {
self.showHTML();
});
_.each(MarkdownShortcuts, function (combo) {
shortcut.add(combo.key, function () {
return self.editor.addMarkdown({style: combo.style});
});
});
this.enableEditor();
},
options: {
markers: {}
},
getEditorValue: function () {
return this.uploadMgr.getEditorValue();
},
unloadDirtyMessage: function () {
return "==============================\n\n" +
"Hey there! It looks like you're in the middle of writing" +
" something and you haven't saved all of your content." +
"\n\nSave before you go!\n\n" +
"==============================";
},
setEditorDirty: function (dirty) {
window.onbeforeunload = dirty ? this.unloadDirtyMessage : null;
},
initUploads: function () {
var filestorage = $('#entry-markdown-content').data('filestorage');
this.$('.js-drop-zone').upload({editor: true, fileStorage: filestorage});
this.$('.js-drop-zone').on('uploadstart', $.proxy(this.disableEditor, this));
this.$('.js-drop-zone').on('uploadfailure', $.proxy(this.enableEditor, this));
this.$('.js-drop-zone').on('uploadsuccess', $.proxy(this.enableEditor, this));
this.$('.js-drop-zone').on('uploadsuccess', this.uploadMgr.handleUpload);
},
enableEditor: function () {
var self = this;
this.editor.setOption("readOnly", false);
this.editor.on('change', function () {
self.setEditorDirty(true);
self.renderPreview();
});
},
disableEditor: function () {
var self = this;
this.editor.setOption("readOnly", "nocursor");
this.editor.off('change', function () {
self.renderPreview();
});
},
showHTML: function () {
showEditorModal: function (content) {
this.addSubview(new Ghost.Views.Modal({
model: {
options: {
close: true,
style: ["wide"],
style: ['wide'],
animation: 'fade'
},
content: {
template: 'copyToHTML',
title: 'Copied HTML'
}
content: content
}
}));
},
showHelp: function () {
var content = {
template: 'markdown',
title: 'Markdown Help'
};
this.showEditorModal(content);
},
showHTML: function () {
var content = {
template: 'copyToHTML',
title: 'Copied HTML'
};
this.showEditorModal(content);
},
render: function () { return this; }
});
MarkerManager = function (editor) {
var markers = {},
uploadPrefix = 'image_upload',
uploadId = 1;
function addMarker(line, ln) {
var marker,
magicId = '{<' + uploadId + '>}';
editor.setLine(ln, magicId + line.text);
marker = editor.markText(
{line: ln, ch: 0},
{line: ln, ch: (magicId.length)},
{collapsed: true}
);
markers[uploadPrefix + '_' + uploadId] = marker;
uploadId += 1;
}
function getMarkerRegexForId(id) {
id = id.replace('image_upload_', '');
return new RegExp('\\{<' + id + '>\\}', 'gmi');
}
function stripMarkerFromLine(line) {
var markerText = line.text.match(markerRegex),
ln = editor.getLineNumber(line);
if (markerText) {
editor.replaceRange('', {line: ln, ch: markerText.index}, {line: ln, ch: markerText.index + markerText[0].length});
}
}
function findAndStripMarker(id) {
editor.eachLine(function (line) {
var markerText = getMarkerRegexForId(id).exec(line.text),
ln;
if (markerText) {
ln = editor.getLineNumber(line);
editor.replaceRange('', {line: ln, ch: markerText.index}, {line: ln, ch: markerText.index + markerText[0].length});
}
});
}
function removeMarker(id, marker, line) {
delete markers[id];
marker.clear();
if (line) {
stripMarkerFromLine(line);
} else {
findAndStripMarker(id);
}
}
function checkMarkers() {
_.each(markers, function (marker, id) {
var line;
marker = markers[id];
if (marker.find()) {
line = editor.getLineHandle(marker.find().from.line);
if (!line.text.match(imageMarkdownRegex)) {
removeMarker(id, marker, line);
}
} else {
removeMarker(id, marker);
}
});
}
function initMarkers(line) {
var isImage = line.text.match(imageMarkdownRegex),
hasMarker = line.text.match(markerRegex);
if (isImage && !hasMarker) {
addMarker(line, editor.getLineNumber(line));
}
}
// public api
_.extend(this, {
markers: markers,
checkMarkers: checkMarkers,
addMarker: addMarker,
stripMarkerFromLine: stripMarkerFromLine,
getMarkerRegexForId: getMarkerRegexForId
});
// Initialise
editor.eachLine(initMarkers);
};
UploadManager = function (editor) {
var markerMgr = new MarkerManager(editor);
function findLine(result_id) {
// try to find the right line to replace
if (markerMgr.markers.hasOwnProperty(result_id) && markerMgr.markers[result_id].find()) {
return editor.getLineHandle(markerMgr.markers[result_id].find().from.line);
}
return false;
}
function checkLine(ln, mode) {
var line = editor.getLineHandle(ln),
isImage = line.text.match(imageMarkdownRegex),
hasMarker;
// We care if it is an image
if (isImage) {
hasMarker = line.text.match(markerRegex);
if (hasMarker && mode === 'paste') {
// this could be a duplicate, and won't be a real marker
markerMgr.stripMarkerFromLine(line);
}
if (!hasMarker) {
markerMgr.addMarker(line, ln);
}
}
// TODO: hasMarker but no image?
}
function handleUpload(e, result_src) {
/*jslint regexp: true, bitwise: true */
var line = findLine($(e.currentTarget).attr('id')),
lineNumber = editor.getLineNumber(line),
match = line.text.match(/\([^\n]*\)?/),
replacement = '(http://)';
/*jslint regexp: false, bitwise: false */
if (match) {
// simple case, we have the parenthesis
editor.setSelection({line: lineNumber, ch: match.index + 1}, {line: lineNumber, ch: match.index + match[0].length - 1});
} else {
match = line.text.match(/\]/);
if (match) {
editor.replaceRange(
replacement,
{line: lineNumber, ch: match.index + 1},
{line: lineNumber, ch: match.index + 1}
);
editor.setSelection(
{line: lineNumber, ch: match.index + 2},
{line: lineNumber, ch: match.index + replacement.length }
);
}
}
editor.replaceSelection(result_src);
}
function getEditorValue() {
var value = editor.getValue();
_.each(markerMgr.markers, function (marker, id) {
/*jshint unused:false*/
value = value.replace(markerMgr.getMarkerRegexForId(id), '');
});
return value;
}
// Public API
_.extend(this, {
getEditorValue: getEditorValue,
handleUpload: handleUpload
});
// initialise
editor.on('change', function (cm, changeObj) {
/*jshint unused:false*/
var linesChanged = _.range(changeObj.from.line, changeObj.from.line + changeObj.text.length);
_.each(linesChanged, function (ln) {
checkLine(ln, changeObj.origin);
});
// Is this a line which may have had a marker on it?
markerMgr.checkMarkers();
});
};
}());
}());

View File

@ -94,7 +94,8 @@
var name = this.$('.name').val(),
email = this.$('.email').val(),
password = this.$('.password').val(),
validationErrors = [];
validationErrors = [],
self = this;
if (!validator.isLength(name, 1)) {
validationErrors.push("Please enter a name.");
@ -131,7 +132,7 @@
window.location.href = msg.redirect;
},
error: function (xhr) {
this.submitted = "no";
self.submitted = "no";
Ghost.notifications.clearEverything();
Ghost.notifications.addItem({
type: 'error',

View File

@ -76,7 +76,7 @@
// and then update the placeholder value.
if (title) {
$.ajax({
url: Ghost.paths.apiRoot + '/posts/getSlug/' + encodeURIComponent(title) + '/',
url: Ghost.paths.apiRoot + '/posts/slug/' + encodeURIComponent(title) + '/',
success: function (result) {
$postSettingSlugEl.attr('placeholder', result);
}

View File

@ -220,14 +220,8 @@
} else {
data[key] = this.$('.js-upload-target').attr('src');
}
self.model.save(data, {
success: self.saveSuccess,
error: self.saveError
}).then(function () {
self.saveSettings();
});
self.model.set(data);
self.saveSettings();
return true;
},
buttonClass: "button-save right",
@ -298,12 +292,8 @@
} else {
data[key] = this.$('.js-upload-target').attr('src');
}
self.model.save(data, {
success: self.saveSuccess,
error: self.saveError
}).then(function () {
self.saveUser();
});
self.model.set(data);
self.saveUser(data);
return true;
},
buttonClass: "button-save right",

View File

@ -38,7 +38,7 @@ db = {
return api.settings.read({ key: 'databaseVersion' }).then(function (setting) {
return when(setting.value);
}, function () {
return when('001');
return when('002');
}).then(function (version) {
databaseVersion = version;
// Read the file contents
@ -82,6 +82,9 @@ db = {
return when.resolve({message: 'Posts, tags and other data successfully imported'});
}).otherwise(function importFailure(error) {
return when.reject({code: 500, message: error.message || error});
}).finally(function () {
// Unlink the file after import
return nodefn.call(fs.unlink, options.importfile.path);
});
},
'deleteAllContent': function () {

View File

@ -26,9 +26,9 @@ function cacheInvalidationHeader(req, result) {
if (method === 'POST' || method === 'PUT' || method === 'DELETE') {
if (endpoint === 'settings' || endpoint === 'users' || endpoint === 'db') {
cacheInvalidate = "/*";
cacheInvalidate = '/*';
} else if (endpoint === 'posts') {
cacheInvalidate = "/, /page/*, /rss/, /rss/*";
cacheInvalidate = '/, /page/*, /rss/, /rss/*, /tag/*';
if (id && jsonResult.slug) {
return config.urlForPost(settings, jsonResult).then(function (postUrl) {
return cacheInvalidate + ', ' + postUrl;

View File

@ -47,7 +47,7 @@ posts = {
});
},
getSlug: function getSlug(args) {
generateSlug: function getSlug(args) {
return dataProvider.Base.Model.generateSlug(dataProvider.Post, args.title, {status: 'all'}).then(function (slug) {
if (slug) {
return slug;

View File

@ -9,7 +9,6 @@ var _ = require('lodash'),
// Paths for views
defaultErrorTemplatePath = path.resolve(config().paths.adminViews, 'user-error.hbs'),
userErrorTemplatePath = path.resolve(config().paths.themePath, 'error.hbs'),
userErrorTemplateExists = false;
// This is not useful but required for jshint
@ -19,9 +18,8 @@ colors.setTheme({silly: 'rainbow'});
* Basic error handling helpers
*/
errors = {
updateActiveTheme: function (activeTheme, hasErrorTemplate) {
userErrorTemplatePath = path.resolve(config().paths.themePath, activeTheme, 'error.hbs');
userErrorTemplateExists = hasErrorTemplate;
updateActiveTheme: function (activeTheme) {
userErrorTemplateExists = config().paths.availableThemes[activeTheme].hasOwnProperty('error.hbs');
},
throwError: function (err) {
@ -47,23 +45,27 @@ errors = {
process.env.NODE_ENV === 'staging' ||
process.env.NODE_ENV === 'production')) {
console.log('\nWarning:'.yellow, warn.yellow);
var msgs = ['\nWarning:'.yellow, warn.yellow, '\n'];
if (context) {
console.log(context.white);
msgs.push(context.white, '\n');
}
if (help) {
console.log(help.green);
msgs.push(help.green);
}
// add a new line
console.log('');
msgs.push('\n');
console.log.apply(console, msgs);
}
},
logError: function (err, context, help) {
var stack = err ? err.stack : null;
var stack = err ? err.stack : null,
msgs;
if (err) {
err = err.message || err || 'An unknown error occurred.';
} else {
@ -75,22 +77,24 @@ errors = {
process.env.NODE_ENV === 'staging' ||
process.env.NODE_ENV === 'production')) {
console.error('\nERROR:'.red, err.red);
msgs = ['\nERROR:'.red, err.red, '\n'];
if (context) {
console.error(context.white);
msgs.push(context.white, '\n');
}
if (help) {
console.error(help.green);
msgs.push(help.green);
}
// add a new line
console.error('');
msgs.push('\n');
if (stack) {
console.error(stack, '\n');
msgs.push(stack, '\n');
}
console.error.apply(console, msgs);
}
},

View File

@ -121,7 +121,7 @@ coreHelpers.page_url = function (context, block) {
//
coreHelpers.pageUrl = function (context, block) {
errors.logWarn('Warning: pageUrl is deprecated, please use page_url instead\n' +
'The helper pageUrl has been replaced with page_url in Ghost 0.5, and will be removed entirely in Ghost 0.6\n' +
'The helper pageUrl has been replaced with page_url in Ghost 0.4.2, and will be removed entirely in Ghost 0.6\n' +
'In your theme\'s pagination.hbs file, pageUrl should be renamed to page_url');
/*jshint unused:false*/
@ -368,14 +368,19 @@ coreHelpers.body_class = function (options) {
tags = this.post && this.post.tags ? this.post.tags : this.tags || [],
page = this.post && this.post.page ? this.post.page : this.page || false;
if (_.isString(this.relativeUrl) && this.relativeUrl.match(/\/(page|tag)/)) {
if (_.isString(this.relativeUrl) && this.relativeUrl.match(/\/(page\/\d)/)) {
classes.push('archive-template');
} else if (!this.relativeUrl || this.relativeUrl === '/' || this.relativeUrl === '') {
classes.push('home-template');
} else {
} else if (post) {
classes.push('post-template');
}
if (this.tag !== undefined) {
classes.push('tag-template');
classes.push('tag-' + this.tag.slug);
}
if (tags) {
classes = classes.concat(tags.map(function (tag) { return 'tag-' + tag.slug; }));
}
@ -459,10 +464,11 @@ coreHelpers.ghost_head = function (options) {
coreHelpers.ghost_foot = function (options) {
/*jshint unused:false*/
var foot = [];
var jquery = isProduction ? 'jquery.min.js' : 'jquery.js',
foot = [];
foot.push(scriptTemplate({
source: config().paths.subdir + '/public/jquery.js',
source: config().paths.subdir + '/public/' + jquery,
version: coreHelpers.assetHash
}));

View File

@ -22,6 +22,7 @@ var crypto = require('crypto'),
packageInfo = require('../../package.json'),
// Variables
httpServer,
dbHash;
// If we're in development mode, require "when/console/monitor"
@ -105,63 +106,58 @@ function builtFilesExist() {
return when.all(deferreds);
}
function startGhost(deferred) {
function ghostStartMessages() {
// Tell users if their node version is not supported, and exit
if (!semver.satisfies(process.versions.node, packageInfo.engines.node)) {
console.log(
"\nERROR: Unsupported version of Node".red,
"\nGhost needs Node version".red,
packageInfo.engines.node.yellow,
"you are using version".red,
process.versions.node.yellow,
"\nPlease go to http://nodejs.org to get a supported version".green
);
return function () {
// Tell users if their node version is not supported, and exit
if (!semver.satisfies(process.versions.node, packageInfo.engines.node)) {
process.exit(0);
}
// Startup & Shutdown messages
if (process.env.NODE_ENV === 'production') {
console.log(
"Ghost is running...".green,
"\nYour blog is now available on",
config().url,
"\nCtrl+C to shut down".grey
);
// ensure that Ghost exits correctly on Ctrl+C
process.on('SIGINT', function () {
console.log(
"\nERROR: Unsupported version of Node".red,
"\nGhost needs Node version".red,
packageInfo.engines.node.yellow,
"you are using version".red,
process.versions.node.yellow,
"\nPlease go to http://nodejs.org to get a supported version".green
"\nGhost has shut down".red,
"\nYour blog is now offline"
);
process.exit(0);
}
// Startup & Shutdown messages
if (process.env.NODE_ENV === 'production') {
console.log(
"Ghost is running...".green,
"\nYour blog is now available on",
config().url,
"\nCtrl+C to shut down".grey
);
// ensure that Ghost exits correctly on Ctrl+C
process.on('SIGINT', function () {
console.log(
"\nGhost has shut down".red,
"\nYour blog is now offline"
);
process.exit(0);
});
} else {
console.log(
("Ghost is running in " + process.env.NODE_ENV + "...").green,
"\nListening on",
});
} else {
console.log(
("Ghost is running in " + process.env.NODE_ENV + "...").green,
"\nListening on",
config.getSocket() || config().server.host + ':' + config().server.port,
"\nUrl configured as:",
config().url,
"\nCtrl+C to shut down".grey
"\nUrl configured as:",
config().url,
"\nCtrl+C to shut down".grey
);
// ensure that Ghost exits correctly on Ctrl+C
process.on('SIGINT', function () {
console.log(
"\nGhost has shutdown".red,
"\nGhost was running for",
Math.round(process.uptime()),
"seconds"
);
// ensure that Ghost exits correctly on Ctrl+C
process.on('SIGINT', function () {
console.log(
"\nGhost has shutdown".red,
"\nGhost was running for",
Math.round(process.uptime()),
"seconds"
);
process.exit(0);
});
}
deferred.resolve();
};
process.exit(0);
});
}
}
// ## Initializes the ghost application.
@ -246,11 +242,11 @@ function init(server) {
// Log all theme errors and warnings
_.each(config().paths.availableThemes._messages.errors, function (error) {
errors.logError(error.message, error.context);
errors.logError(error.message, error.context, error.help);
});
_.each(config().paths.availableThemes._messages.warns, function (warn) {
errors.logWarn(warn.message, warn.context);
errors.logWarn(warn.message, warn.context, warn.help);
});
// ## Start Ghost App
@ -258,21 +254,25 @@ function init(server) {
// Make sure the socket is gone before trying to create another
fs.unlink(config.getSocket(), function (err) {
/*jshint unused:false*/
server.listen(
config.getSocket(),
startGhost(deferred)
httpServer = server.listen(
config.getSocket()
);
fs.chmod(config.getSocket(), '0660');
});
} else {
server.listen(
httpServer = server.listen(
config().server.port,
config().server.host,
startGhost(deferred)
config().server.host
);
}
httpServer.on('listening', function () {
ghostStartMessages();
deferred.resolve(httpServer);
});
return deferred.promise;
});
}

View File

@ -118,7 +118,7 @@ function activateTheme(activeTheme) {
expressServer.set('theme view engine', hbs.express3(hbsOptions));
// Update user error template
errors.updateActiveTheme(activeTheme, config().paths.availableThemes[activeTheme].hasOwnProperty('error'));
errors.updateActiveTheme(activeTheme);
}
// ### ManageAdminAndTheme Middleware

View File

@ -199,36 +199,39 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({
generateSlug: function (Model, base, readOptions) {
var slug,
slugTryCount = 1,
baseName = Model.prototype.tableName.replace(/s$/, ''),
// Look for a post with a matching slug, append an incrementing number if so
checkIfSlugExists = function (slugToFind) {
var args = {slug: slugToFind};
//status is needed for posts
if (readOptions && readOptions.status) {
args.status = readOptions.status;
checkIfSlugExists;
checkIfSlugExists = function (slugToFind) {
var args = {slug: slugToFind};
//status is needed for posts
if (readOptions && readOptions.status) {
args.status = readOptions.status;
}
return Model.findOne(args, readOptions).then(function (found) {
var trimSpace;
if (!found) {
return when.resolve(slugToFind);
}
return Model.findOne(args, readOptions).then(function (found) {
var trimSpace;
if (!found) {
return when.resolve(slugToFind);
}
slugTryCount += 1;
slugTryCount += 1;
// If this is the first time through, add the hyphen
if (slugTryCount === 2) {
slugToFind += '-';
} else {
// Otherwise, trim the number off the end
trimSpace = -(String(slugTryCount - 1).length);
slugToFind = slugToFind.slice(0, trimSpace);
}
// If this is the first time through, add the hyphen
if (slugTryCount === 2) {
slugToFind += '-';
} else {
// Otherwise, trim the number off the end
trimSpace = -(String(slugTryCount - 1).length);
slugToFind = slugToFind.slice(0, trimSpace);
}
slugToFind += slugTryCount;
slugToFind += slugTryCount;
return checkIfSlugExists(slugToFind);
});
};
return checkIfSlugExists(slugToFind);
});
};
slug = base.trim();
@ -248,12 +251,12 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({
slug = slug.charAt(slug.length - 1) === '-' ? slug.substr(0, slug.length - 1) : slug;
// Check the filtered slug doesn't match any of the reserved keywords
slug = /^(ghost|ghost\-admin|admin|wp\-admin|wp\-login|dashboard|logout|login|signin|signup|signout|register|archive|archives|category|categories|tag|tags|page|pages|post|posts|user|users|rss)$/g
.test(slug) ? slug + '-post' : slug;
slug = /^(ghost|ghost\-admin|admin|wp\-admin|wp\-login|dashboard|logout|login|signin|signup|signout|register|archive|archives|category|categories|tag|tags|page|pages|post|posts|user|users|rss|feed)$/g
.test(slug) ? slug + '-' + baseName : slug;
//if slug is empty after trimming use "post"
if (!slug) {
slug = 'post';
slug = baseName;
}
// Test for duplicate slugs.
return checkIfSlugExists(slug);

View File

@ -1,5 +1,6 @@
var migrations = require('../data/migration'),
_ = require('lodash');
_ = require('lodash'),
when = require('when');
module.exports = {
Post: require('./post').Post,
@ -20,14 +21,14 @@ module.exports = {
var self = this;
return self.Post.browse().then(function (posts) {
_.each(posts.toJSON(), function (post) {
self.Post.destroy(post.id);
});
return when.all(_.map(posts.toJSON(), function (post) {
return self.Post.destroy(post.id);
}));
}).then(function () {
self.Tag.browse().then(function (tags) {
_.each(tags.toJSON(), function (tag) {
self.Tag.destroy(tag.id);
});
return self.Tag.browse().then(function (tags) {
return when.all(_.map(tags.toJSON(), function (tag) {
return self.Tag.destroy(tag.id);
}));
});
});
}

View File

@ -3,14 +3,14 @@ var _ = require('lodash'),
when = require('when'),
errors = require('../errorHandling'),
Showdown = require('showdown'),
github = require('../../shared/lib/showdown/extensions/github'),
typography = require('../../shared/lib/showdown/extensions/typography'),
converter = new Showdown.converter({extensions: [typography, github]}),
ghostgfm = require('../../shared/lib/showdown/extensions/ghostgfm'),
converter = new Showdown.converter({extensions: [ghostgfm]}),
User = require('./user').User,
Tag = require('./tag').Tag,
Tags = require('./tag').Tags,
ghostBookshelf = require('./base'),
validation = require('../data/validation'),
xmlrpc = require('../xmlrpc'),
Post,
Posts;
@ -29,7 +29,12 @@ Post = ghostBookshelf.Model.extend({
initialize: function () {
var self = this;
this.on('creating', this.creating, this);
this.on('saved', this.updateTags, this);
this.on('saved', function (model, attributes, options) {
if (model.get('status') === 'published') {
xmlrpc.ping(model.attributes);
}
return self.updateTags(model, attributes, options);
});
this.on('saving', function (model, attributes, options) {
return when(self.saving(model, attributes, options)).then(function () {
return self.validate(model, attributes, options);
@ -43,10 +48,21 @@ Post = ghostBookshelf.Model.extend({
saving: function (newPage, attr, options) {
/*jshint unused:false*/
var self = this;
var self = this,
tagsToCheck,
i;
// keep tags for 'saved' event
this.myTags = this.get('tags');
// keep tags for 'saved' event and deduplicate upper/lowercase tags
tagsToCheck = this.get('tags');
this.myTags = [];
_.each(tagsToCheck, function (item) {
for (i = 0; i < self.myTags.length; i = i + 1) {
if (self.myTags[i].name.toLocaleLowerCase() === item.name.toLocaleLowerCase()) {
return;
}
}
self.myTags.push(item);
});
ghostBookshelf.Model.prototype.saving.call(this);

View File

@ -64,7 +64,9 @@ Settings = ghostBookshelf.Model.extend({
if (!_.isObject(_key)) {
_key = { key: _key };
}
return ghostBookshelf.Model.read.call(this, _key);
return when(ghostBookshelf.Model.read.call(this, _key)).then(function (element) {
return element;
});
},
edit: function (_data, t) {

View File

@ -8,13 +8,17 @@ Tag = ghostBookshelf.Model.extend({
tableName: 'tags',
saving: function () {
saving: function (newPage, attr, options) {
/*jshint unused:false*/
var self = this;
ghostBookshelf.Model.prototype.saving.apply(this, arguments);
if (!this.get('slug')) {
// Generating a slug requires a db call to look for conflicting slugs
return ghostBookshelf.Model.generateSlug(Tag, this.get('name'))
if (this.hasChanged('slug') || !this.get('slug')) {
// Pass the new slug through the generator to strip illegal characters, detect duplicates
return ghostBookshelf.Model.generateSlug(Tag, this.get('slug') || this.get('name'),
{transacting: options.transacting})
.then(function (slug) {
self.set({slug: slug});
});

View File

@ -38,7 +38,9 @@ User = ghostBookshelf.Model.extend({
tableName: 'users',
saving: function () {
saving: function (newPage, attr, options) {
/*jshint unused:false*/
var self = this;
// disabling sanitization until we can implement a better version
// this.set('name', this.sanitize('name'));
@ -49,14 +51,14 @@ User = ghostBookshelf.Model.extend({
ghostBookshelf.Model.prototype.saving.apply(this, arguments);
if (!this.get('slug')) {
if (this.hasChanged('slug') || !this.get('slug')) {
// Generating a slug requires a db call to look for conflicting slugs
return ghostBookshelf.Model.generateSlug(User, this.get('name'))
return ghostBookshelf.Model.generateSlug(User, this.get('slug') || this.get('name'),
{transacting: options.transacting})
.then(function (slug) {
self.set({slug: slug});
});
}
},
posts: function () {
@ -175,7 +177,7 @@ User = ghostBookshelf.Model.extend({
});
}
return when(user.set('status', 'active').save()).then(function (user) {
return when(user.set({status : 'active', last_login : new Date()}).save()).then(function (user) {
return user;
});
}, errors.logAndThrowError);

View File

@ -16,7 +16,10 @@ var _ = require('lodash'),
fs.readFile(path, function (error, data) {
if (error) {
messages.errors.push({message: 'Could not read package.json file', context: path});
messages.errors.push({
message: 'Could not read package.json file',
context: path
});
packageDeferred.resolve(false);
return;
}
@ -25,11 +28,19 @@ var _ = require('lodash'),
if (jsonContainer.hasOwnProperty('name') && jsonContainer.hasOwnProperty('version')) {
packageDeferred.resolve(jsonContainer);
} else {
messages.errors.push({message: '"name" or "version" is missing from theme package.json file.', context: path});
messages.errors.push({
message: '"name" or "version" is missing from theme package.json file.',
context: path,
help: 'This will be required in future. Please see http://docs.ghost.org/themes/'
});
packageDeferred.resolve(false);
}
} catch (e) {
messages.errors.push({message: 'Theme package.json file is malformed', context: path});
messages.errors.push({
message: 'Theme package.json file is malformed',
context: path,
help: 'This will be required in future. Please see http://docs.ghost.org/themes/'
});
packageDeferred.resolve(false);
}
});
@ -70,7 +81,7 @@ var _ = require('lodash'),
/*jslint unparam:true*/
if (result.isDirectory()) {
fileDeferred.resolve(readDir(fpath, options, depth + 1, messages));
} else if (depth === 1 && file === "package.json") {
} else if (depth === 1 && file === 'package.json') {
fileDeferred.resolve(parsePackageJson(fpath, messages));
} else {
fileDeferred.resolve(fpath);
@ -96,15 +107,21 @@ var _ = require('lodash'),
return when(readDir(dir, options, depth, messages)).then(function (paths) {
// for all contents of the dir, I'm interested in the ones that are directories and within /theme/
if (typeof paths === "object" && dir.indexOf('theme') !== -1) {
if (typeof paths === 'object' && dir.indexOf('theme') !== -1) {
_.each(paths, function (path, index) {
if (typeof path === 'object' && !path.hasOwnProperty('package.json') && index.indexOf('.') !== 0) {
messages.warns.push({message: 'Theme does not have a package.json file', context: index});
messages.warns.push({
message: 'Found a theme with no package.json file',
context: 'Theme name: ' + index,
help: 'This will be required in future. Please see http://docs.ghost.org/themes/'
});
}
});
}
paths._messages = messages;
return paths;
}).otherwise(function () {
return {'_messages': messages};
});
};

View File

@ -1,6 +1,9 @@
var admin = require('../controllers/admin'),
config = require('../config'),
middleware = require('../middleware').middleware;
middleware = require('../middleware').middleware,
ONE_HOUR_S = 60 * 60,
ONE_YEAR_S = 365 * 24 * ONE_HOUR_S;
module.exports = function (server) {
// Have ember route look for hits first
@ -11,24 +14,24 @@ module.exports = function (server) {
// ### Admin routes
server.get('/logout/', function redirect(req, res) {
/*jslint unparam:true*/
res.set({'Cache-Control': 'public, max-age=' + ONE_YEAR_S});
res.redirect(301, subdir + '/ghost/signout/');
});
server.get('/signout/', function redirect(req, res) {
/*jslint unparam:true*/
res.set({'Cache-Control': 'public, max-age=' + ONE_YEAR_S});
res.redirect(301, subdir + '/ghost/signout/');
});
server.get('/signin/', function redirect(req, res) {
/*jslint unparam:true*/
res.set({'Cache-Control': 'public, max-age=' + ONE_YEAR_S});
res.redirect(301, subdir + '/ghost/signin/');
});
server.get('/signup/', function redirect(req, res) {
/*jslint unparam:true*/
res.set({'Cache-Control': 'public, max-age=' + ONE_YEAR_S});
res.redirect(301, subdir + '/ghost/signup/');
});
server.get('/ghost/login/', function redirect(req, res) {
/*jslint unparam:true*/
res.redirect(301, subdir + '/ghost/signin/');
});
server.get('/ghost/signout/', admin.signout);
server.get('/ghost/signin/', middleware.redirectToSignup, middleware.redirectToDashboard, admin.signin);

View File

@ -9,7 +9,7 @@ module.exports = function (server) {
server.get('/ghost/api/v0.1/posts/:id', api.requestHandler(api.posts.read));
server.put('/ghost/api/v0.1/posts/:id', api.requestHandler(api.posts.edit));
server.del('/ghost/api/v0.1/posts/:id', api.requestHandler(api.posts.destroy));
server.get('/ghost/api/v0.1/posts/getSlug/:title', middleware.authAPI, api.requestHandler(api.posts.getSlug));
server.get('/ghost/api/v0.1/posts/slug/:title', middleware.authAPI, api.requestHandler(api.posts.generateSlug));
// #### Settings
server.get('/ghost/api/v0.1/settings/', api.requestHandler(api.settings.browse));
server.get('/ghost/api/v0.1/settings/:key/', api.requestHandler(api.settings.read));

View File

@ -1,11 +1,22 @@
var frontend = require('../controllers/frontend');
var frontend = require('../controllers/frontend'),
config = require('../config'),
ONE_HOUR_S = 60 * 60,
ONE_YEAR_S = 365 * 24 * ONE_HOUR_S;
module.exports = function (server) {
/*jslint regexp: true */
var subdir = config().paths.subdir;
// ### Frontend routes
server.get('/rss/', frontend.rss);
server.get('/rss/:page/', frontend.rss);
server.get('/feed/', function redirect(req, res) {
/*jshint unused:true*/
res.set({'Cache-Control': 'public, max-age=' + ONE_YEAR_S});
res.redirect(301, subdir + '/rss/');
});
server.get('/tag/:slug/rss/', frontend.rss);
server.get('/tag/:slug/rss/:page/', frontend.rss);
server.get('/tag/:slug/page/:page/', frontend.tag);
@ -13,4 +24,6 @@ module.exports = function (server) {
server.get('/page/:page/', frontend.homepage);
server.get('/', frontend.homepage);
server.get('*', frontend.single);
};

View File

@ -41,7 +41,7 @@ baseStore = {
'getUniqueFileName': function (store, image, targetDir) {
var done = when.defer(),
ext = path.extname(image.name),
name = path.basename(image.name, ext).replace(/[\W]/gi, '_');
name = path.basename(image.name, ext).replace(/[\W]/gi, '-');
this.generateUnique(store, targetDir, name, ext, 0, done);

View File

@ -33,7 +33,7 @@ localFileStore = _.extend(baseStore, {
}).then(function () {
// The src for the image must be in URI format, not a file system path, which in Windows uses \
// For local file system storage can use relative path so add a slash
var fullUrl = (config().paths.subdir + '/' + path.relative(config().paths.appRoot, targetFilename)).replace(new RegExp('\\' + path.sep, 'g'), '/');
var fullUrl = (config().paths.subdir + '/' + config().paths.imagesRelPath + '/' + path.relative(config().paths.imagesPath, targetFilename)).replace(new RegExp('\\' + path.sep, 'g'), '/');
return saved.resolve(fullUrl);
}).otherwise(function (e) {
errors.logError(e);

View File

@ -1,4 +1,15 @@
{{!< default}}
<style>
/* Additional styling for touch editor, will be moved in future */
.touch-editor #entry-markdown {
padding: 15px;
margin-bottom: 40px;
font-family: monospace;
font-size: 1.4em;
line-height: 1.3em;
color: #242628;
}
</style>
<section class="entry-container">
<header>
<section class="box entry-title">

65
core/server/xmlrpc.js Normal file
View File

@ -0,0 +1,65 @@
var _ = require('lodash'),
config = require('./config'),
errors = require('./errorHandling'),
http = require('http'),
xml = require('xml'),
pingList;
// ToDo: Make this configurable
pingList = [
{ host: 'blogsearch.google.com', path: '/ping/RPC2' },
{ host: 'rpc.pingomatic.com', path: '/' }
];
function ping(post) {
var pingXML,
title = post.title;
// Only ping when in production and not a page
if (process.env.NODE_ENV !== 'production' || post.page) {
return;
}
// Need to require here because of circular dependency
return config.urlForPost(require('./api').settings, post, true).then(function (url) {
// Build XML object.
pingXML = xml({
methodCall: [
{ methodName: 'weblogUpdate.ping' },
{
params: [{
param: [{ value: [{ string: title }]}],
}, {
param: [{ value: [{ string: url }]}],
}]
}
]
}, {declaration: true});
// Ping each of the defined services.
_.each(pingList, function (pingHost) {
var options = {
hostname: pingHost.host,
path: pingHost.path,
method: 'POST'
},
req;
req = http.request(options);
req.write(pingXML);
req.on('error', function (error) {
errors.logError(
error,
"Pinging services for updates on your blog failed, your blog will continue to function.",
"If you get this error repeatedly, please seek help from https://ghost.org/forum."
);
});
req.end();
});
});
}
module.exports = {
ping: ping
};

View File

@ -1,10 +1,16 @@
/* jshint node:true, browser:true */
// Ghost GFM
// Taken and extended from the Showdown Github Extension (WIP)
// Makes a number of pre and post-processing changes to the way markdown is handled
//
// Github Extension (WIP)
// ~~strike-through~~ -> <del>strike-through</del>
//
// ~~strike-through~~ -> <del>strike-through</del> (Pre)
// GFM newlines & underscores (Pre)
// 4 or more underscores (Pre)
// autolinking / custom image handling (Post)
(function () {
var github = function (converter) {
var ghostgfm = function () {
return [
{
// strike-through
@ -73,6 +79,17 @@
return text;
}
},
// 4 or more inline underscores e.g. Ghost rocks my _____!
{
type: 'lang',
filter: function (text) {
return text.replace(/([^_\n\r])(_{4,})/g, function (match, prefix, underscores) {
return prefix + underscores.replace(/_/g, '&#95;');
});
}
},
{
// GFM autolinking & custom image handling, happens AFTER showdown
type : 'html',
@ -131,7 +148,11 @@
};
// Client-side export
if (typeof window !== 'undefined' && window.Showdown && window.Showdown.extensions) { window.Showdown.extensions.github = github; }
if (typeof window !== 'undefined' && window.Showdown && window.Showdown.extensions) {
window.Showdown.extensions.ghostgfm = ghostgfm;
}
// Server-side export
if (typeof module !== 'undefined') module.exports = github;
if (typeof module !== 'undefined') {
module.exports = ghostgfm;
}
}());

View File

@ -1,6 +1,15 @@
/* jshint node:true, browser:true */
// Ghost Image Preview
//
// Manages the conversion of image markdown `![]()` from markdown into the HTML image preview
// This provides a dropzone and other interface elements for adding images
// Is only used in the admin client.
var Ghost = Ghost || {};
(function () {
var ghostdown = function () {
var ghostimagepreview = function () {
return [
// ![] image syntax
{
@ -12,25 +21,24 @@
pathRegex = /^(\/)?([^\/\0]+(\/)?)+$/i;
return text.replace(imageMarkdownRegex, function (match, key, alt, src) {
var result = "";
var result = '',
output;
if (src && (src.match(uriRegex) || src.match(pathRegex))) {
result = '<img class="js-upload-target" src="' + src + '"/>';
}
return '<section id="image_upload_' + key + '" class="js-drop-zone image-uploader">' + result +
'<div class="description">Add image of <strong>' + alt + '</strong></div>' +
'<input data-url="upload" class="js-fileupload main fileupload" type="file" name="uploadimage">' +
'</section>';
});
}
},
// 4 or more inline underscores e.g. Ghost rocks my _____!
{
type: 'lang',
filter: function (text) {
return text.replace(/([^_\n\r])(_{4,})/g, function (match, prefix, underscores) {
return prefix + underscores.replace(/_/g, '&#95;');
if (Ghost && Ghost.touchEditor) {
output = '<section class="image-uploader">' +
result + '<div class="description">Mobile uploads coming soon</div></section>';
} else {
output = '<section id="image_upload_' + key + '" class="js-drop-zone image-uploader">' +
result + '<div class="description">Add image of <strong>' + alt + '</strong></div>' +
'<input data-url="upload" class="js-fileupload main fileupload" type="file" name="uploadimage">' +
'</section>';
}
return output;
});
}
}
@ -39,10 +47,10 @@
// Client-side export
if (typeof window !== 'undefined' && window.Showdown && window.Showdown.extensions) {
window.Showdown.extensions.ghostdown = ghostdown;
window.Showdown.extensions.ghostimagepreview = ghostimagepreview;
}
// Server-side export
if (typeof module !== 'undefined') {
module.exports = ghostdown;
module.exports = ghostimagepreview;
}
}());

View File

@ -1,114 +0,0 @@
/*global module */
//
// Replaces straight quotes with curly ones, -- and --- with en dash and em
// dash respectively, and ... with horizontal ellipses.
//
(function () {
var typography = function () {
return [
{
type: "lang",
filter: function (text) {
var fCodeblocks = {}, nCodeblocks = {}, iCodeblocks = {},
e = {
endash: '\u2009\u2013\u2009', // U+2009 = thin space
emdash: '\u2014',
lsquo: '\u2018',
rsquo: '\u2019',
ldquo: '\u201c',
rdquo: '\u201d',
hellip: '\u2026'
},
i;
// Extract fenced code blocks.
i = -1;
text = text.replace(/```((?:.|\n)+?)```/g,
function (match, code) {
i += 1;
fCodeblocks[i] = "```" + code + "```";
return "{typog-fcb-" + i + "}";
});
// Extract indented code blocks.
i = -1;
text = text.replace(/((\n+([ ]{4}|\t).+)+)/g,
function (match, code) {
i += 1;
nCodeblocks[i] = " " + code;
return "{typog-ncb-" + i + "}";
});
// Extract inline code blocks
i = -1;
text = text.replace(/`(.+)`/g, function (match, code) {
i += 1;
iCodeblocks[i] = "`" + code + "`";
return "{typog-icb-" + i + "}";
});
// Perform typographic symbol replacement.
// Double quotes. There might be a reason this doesn't use
// the same \b matching style as the single quotes, but I
// can't remember what it is :(
text = text.
// Opening quotes
replace(/"([\w'])/g, e.ldquo + "$1").
// All the rest
replace(/"/g, e.rdquo);
// Single quotes/apostrophes
text = text.
// Apostrophes first
replace(/\b'\b/g, e.rsquo).
// Opening quotes
replace(/'\b/g, e.lsquo).
// All the rest
replace(/'/g, e.rsquo);
// Dashes
text = text.
// Don't replace lines containing only hyphens
replace(/^-+$/gm, "{typog-hr}").
replace(/---/g, e.emdash).
replace(/ -- /g, e.endash).
replace(/{typog-hr}/g, "----");
// Ellipses.
text = text.replace(/\.{3}/g, e.hellip);
// Restore fenced code blocks.
text = text.replace(/{typog-fcb-([0-9]+)}/g, function (x, y) {
return fCodeblocks[y];
});
// Restore indented code blocks.
text = text.replace(/{typog-ncb-([0-9]+)}/g, function (x, y) {
return nCodeblocks[y];
});
// Restore inline code blocks.
text = text.replace(/{typog-icb-([0-9]+)}/g, function (x, y) {
return iCodeblocks[y];
});
return text;
}
}
];
};
// Client-side export
if (typeof window !== 'undefined' && window.Showdown && window.Showdown.extensions) {
window.Showdown.extensions.typography = typography;
}
// Server-side export
if (typeof module !== 'undefined') {
module.exports = typography;
}
}());

View File

@ -1,6 +1,6 @@
/*globals casper, __utils__, url, testPost */
CasperTest.begin("Content screen is correct", 20, function suite(test) {
CasperTest.begin("Content screen is correct", 12, function suite(test) {
// Create a sample post
casper.thenOpen(url + 'ghost/editor/', function testTitleAndUrl() {
test.assertTitle('Ghost Admin', 'Ghost admin has no title');
@ -27,18 +27,6 @@ CasperTest.begin("Content screen is correct", 20, function suite(test) {
test.assertUrlMatch(/ghost\/content\/$/, "Ghost doesn't require login this time");
});
casper.then(function testMenus() {
test.assertExists("#main-menu", "Main menu is present");
test.assertSelectorHasText("#main-menu .content a", "Content");
test.assertSelectorHasText("#main-menu .editor a", "New Post");
test.assertSelectorHasText("#main-menu .settings a", "Settings");
test.assertExists("#usermenu", "User menu is present");
test.assertSelectorHasText("#usermenu .usermenu-profile a", "Your Profile");
test.assertSelectorHasText("#usermenu .usermenu-help a", "Help / Support");
test.assertSelectorHasText("#usermenu .usermenu-signout a", "Sign Out");
});
casper.then(function testViews() {
test.assertExists(".content-view-container", "Content main view is present");
test.assertExists(".content-list-content", "Content list view is present");
@ -129,4 +117,59 @@ CasperTest.begin("Posts can be marked as featured", 12, function suite(test) {
}, function onTimeout() {
test.assert(false, 'Success notification wont go away:(');
});
});
CasperTest.begin('Admin navigation bar is correct', 28, function suite(test) {
casper.thenOpen(url + 'ghost/content/', function testTitleAndUrl() {
test.assertTitle('Ghost Admin', 'Ghost admin has no title');
test.assertUrlMatch(/ghost\/content\/$/, "Ghost doesn't require login this time");
});
casper.then(function testNavItems() {
test.assertExists('a.ghost-logo', 'Ghost logo home page link exists');
test.assertEquals(this.getElementAttribute('a.ghost-logo', 'href'), '/', 'Ghost logo href is correct');
test.assertExists('#main-menu li.content a', 'Content nav item exists');
test.assertSelectorHasText('#main-menu li.content a', 'Content', 'Content nav item has correct text');
test.assertEquals(this.getElementAttribute('#main-menu li.content a', 'href'), '/ghost/', 'Content href is correct');
test.assertEval(function testContentIsNotActive() {
return document.querySelector('#main-menu li.content').classList.contains('active');
}, 'Content nav item is marked active');
test.assertExists('#main-menu li.editor a', 'Editor nav item exists');
test.assertSelectorHasText('#main-menu li.editor a', 'New Post', 'Editor nav item has correct text');
test.assertEquals(this.getElementAttribute('#main-menu li.editor a', 'href'), '/ghost/editor/', 'Editor href is correct');
test.assertEval(function testEditorIsNotActive() {
return !document.querySelector('#main-menu li.editor').classList.contains('active');
}, 'Editor nav item is not marked active');
test.assertExists('#main-menu li.settings a', 'Settings nav item exists');
test.assertSelectorHasText('#main-menu li.settings a', 'Settings', 'Settings nav item has correct text');
test.assertEquals(this.getElementAttribute('#main-menu li.settings a', 'href'), '/ghost/settings/', 'Settings href is correct');
test.assertEval(function testSettingsIsActive() {
return !document.querySelector('#main-menu li.settings').classList.contains('active');
}, 'Settings nav item is not marked active');
});
casper.then(function testUserMenuNotVisible() {
test.assertExists('#usermenu', 'User menu nav item exists');
test.assertNotVisible('#usermenu ul.overlay', 'User menu should not be visible');
});
casper.thenClick('#usermenu a');
casper.waitForSelector('#usermenu ul.overlay', function then() {
test.assertVisible('#usermenu ul.overlay', 'User menu should be visible');
test.assertExists('#usermenu li.usermenu-profile a', 'Profile menu item exists');
test.assertSelectorHasText('#usermenu li.usermenu-profile a', 'Your Profile', 'Profile menu item has correct text');
test.assertEquals(this.getElementAttribute('li.usermenu-profile a', 'href'), '/ghost/settings/user/', 'Profile href is correct');
test.assertExists('#usermenu li.usermenu-help a', 'Help menu item exists');
test.assertSelectorHasText('#usermenu li.usermenu-help a', 'Help / Support', 'Help menu item has correct text');
test.assertEquals(this.getElementAttribute('li.usermenu-help a', 'href'), 'http://ghost.org/forum/', 'Help href is correct');
test.assertExists('#usermenu li.usermenu-signout a', 'Sign Out menu item exists');
test.assertSelectorHasText('#usermenu li.usermenu-signout a', 'Sign Out', 'Sign Out menu item has correct text');
test.assertEquals(this.getElementAttribute('#usermenu li.usermenu-signout a', 'href'), '/ghost/signout/', 'Sign Out href is correct');
});
});

View File

@ -228,13 +228,12 @@ CasperTest.begin("Tag editor", 6, function suite(test) {
casper.thenClick(createdTagSelector);
casper.then(function () {
test.assertDoesntExist(createdTagSelector, "clicking the tag should delete the tag");
casper.waitWhileSelector(createdTagSelector, function onSuccess() {
test.assert(true, "clicking the tag should delete the tag");
});
});
CasperTest.begin("Post settings menu", 17, function suite(test) {
CasperTest.begin("Post settings menu", 18, function suite(test) {
casper.thenOpen(url + "ghost/editor/", function testTitleAndUrl() {
test.assertTitle("Ghost Admin", "Ghost admin has no title");
});
@ -271,7 +270,11 @@ CasperTest.begin("Post settings menu", 17, function suite(test) {
casper.thenClick("#publish-bar a.post-settings");
casper.waitUntilVisible("#publish-bar .post-settings-menu", function onSuccess() {
test.assertVisible(".post-settings-menu a.delete", "delete post button should be visible for saved drafts");
test.assert(true, "post settings menu should be visible after clicking post-settings icon");
});
casper.waitUntilVisible(".post-settings-menu a.delete", function onSuccess() {
test.assert(true, "delete post button should be visible for saved drafts");
});
// Test Static Page conversion
@ -310,7 +313,9 @@ CasperTest.begin("Post settings menu", 17, function suite(test) {
casper.thenClick("#publish-bar a.post-settings");
casper.thenClick(".post-settings-menu a.delete");
casper.thenClick("#modal-container .js-button-accept");
casper.waitUntilVisible("#modal-container", function onSuccess() {
casper.thenClick("#modal-container .js-button-accept");
});
casper.waitForUrl(/ghost\/content\/$/, function onSuccess() {
test.assert(true, "clicking the delete post button should bring us to the content page");
@ -446,3 +451,58 @@ CasperTest.begin('Publish menu - existing post', 22, function suite(test) {
test.assert(false, 'Publish split button should have .splitbutton-delete');
});
});
CasperTest.begin('Admin navigation bar is correct', 28, function suite(test) {
casper.thenOpen(url + 'ghost/editor/', function testTitleAndUrl() {
test.assertTitle('Ghost Admin', 'Ghost admin has no title');
test.assertUrlMatch(/ghost\/editor\/$/, "Ghost doesn't require login this time");
});
casper.then(function testNavItems() {
test.assertExists('a.ghost-logo', 'Ghost logo home page link exists');
test.assertEquals(this.getElementAttribute('a.ghost-logo', 'href'), '/', 'Ghost logo href is correct');
test.assertExists('#main-menu li.content a', 'Content nav item exists');
test.assertSelectorHasText('#main-menu li.content a', 'Content', 'Content nav item has correct text');
test.assertEquals(this.getElementAttribute('#main-menu li.content a', 'href'), '/ghost/', 'Content href is correct');
test.assertEval(function testContentIsNotActive() {
return !document.querySelector('#main-menu li.content').classList.contains('active');
}, 'Content nav item is not marked active');
test.assertExists('#main-menu li.editor a', 'Editor nav item exists');
test.assertSelectorHasText('#main-menu li.editor a', 'New Post', 'Editor nav item has correct text');
test.assertEquals(this.getElementAttribute('#main-menu li.editor a', 'href'), '/ghost/editor/', 'Editor href is correct');
test.assertEval(function testEditorIsNotActive() {
return document.querySelector('#main-menu li.editor').classList.contains('active');
}, 'Editor nav item is marked active');
test.assertExists('#main-menu li.settings a', 'Settings nav item exists');
test.assertSelectorHasText('#main-menu li.settings a', 'Settings', 'Settings nav item has correct text');
test.assertEquals(this.getElementAttribute('#main-menu li.settings a', 'href'), '/ghost/settings/', 'Settings href is correct');
test.assertEval(function testSettingsIsActive() {
return !document.querySelector('#main-menu li.settings').classList.contains('active');
}, 'Settings nav item is not marked active');
});
casper.then(function testUserMenuNotVisible() {
test.assertExists('#usermenu', 'User menu nav item exists');
test.assertNotVisible('#usermenu ul.overlay', 'User menu should not be visible');
});
casper.thenClick('#usermenu a');
casper.waitForSelector('#usermenu ul.overlay', function then() {
test.assertVisible('#usermenu ul.overlay', 'User menu should be visible');
test.assertExists('#usermenu li.usermenu-profile a', 'Profile menu item exists');
test.assertSelectorHasText('#usermenu li.usermenu-profile a', 'Your Profile', 'Profile menu item has correct text');
test.assertEquals(this.getElementAttribute('li.usermenu-profile a', 'href'), '/ghost/settings/user/', 'Profile href is correct');
test.assertExists('#usermenu li.usermenu-help a', 'Help menu item exists');
test.assertSelectorHasText('#usermenu li.usermenu-help a', 'Help / Support', 'Help menu item has correct text');
test.assertEquals(this.getElementAttribute('#usermenu li.usermenu-help a', 'href'), 'http://ghost.org/forum/', 'Help href is correct');
test.assertExists('#usermenu li.usermenu-signout a', 'Sign Out menu item exists');
test.assertSelectorHasText('#usermenu li.usermenu-signout a', 'Sign Out', 'Sign Out menu item has correct text');
test.assertEquals(this.getElementAttribute('#usermenu li.usermenu-signout a', 'href'), '/ghost/signout/', 'Sign Out href is correct');
});
});

View File

@ -113,7 +113,7 @@ CasperTest.begin("Login limit is in place", 3, function suite(test) {
}, true);
CasperTest.begin("Can login to Ghost", 4, function suite(test) {
casper.thenOpen(url + "ghost/login/", function testTitle() {
casper.thenOpen(url + "ghost/signin/", function testTitle() {
test.assertTitle("Ghost Admin", "Ghost admin has no title");
});

View File

@ -1,5 +1,7 @@
/*globals casper, __utils__, url */
// var navTest = require('../partials/partial_test');
CasperTest.begin("Settings screen is correct", 18, function suite(test) {
casper.thenOpen(url + "ghost/settings/", function testTitleAndUrl() {
test.assertTitle("Ghost Admin", "Ghost admin has no title");
@ -388,3 +390,58 @@ CasperTest.begin('Ensure user location field length validation', 3, function sui
test.fail('Location field length error did not appear');
}, 2000);
});
CasperTest.begin('Admin navigation bar is correct', 28, function suite(test) {
casper.thenOpen(url + 'ghost/settings/', function testTitleAndUrl() {
test.assertTitle('Ghost Admin', 'Ghost admin has no title');
test.assertUrlMatch(/ghost\/settings\/general\/$/, "Ghost doesn't require login this time");
});
casper.then(function testNavItems() {
test.assertExists('a.ghost-logo', 'Ghost logo home page link exists');
test.assertEquals(this.getElementAttribute('a.ghost-logo', 'href'), '/', 'Ghost logo href is correct');
test.assertExists('#main-menu li.content a', 'Content nav item exists');
test.assertSelectorHasText('#main-menu li.content a', 'Content', 'Content nav item has correct text');
test.assertEquals(this.getElementAttribute('#main-menu li.content a', 'href'), '/ghost/', 'Content href is correct');
test.assertEval(function testContentIsNotActive() {
return !document.querySelector('#main-menu li.content').classList.contains('active');
}, 'Content nav item is not marked active');
test.assertExists('#main-menu li.editor a', 'Editor nav item exists');
test.assertSelectorHasText('#main-menu li.editor a', 'New Post', 'Editor nav item has correct text');
test.assertEquals(this.getElementAttribute('#main-menu li.editor a', 'href'), '/ghost/editor/', 'Editor href is correct');
test.assertEval(function testEditorIsNotActive() {
return !document.querySelector('#main-menu li.editor').classList.contains('active');
}, 'Editor nav item is not marked active');
test.assertExists('#main-menu li.settings a', 'Settings nav item exists');
test.assertSelectorHasText('#main-menu li.settings a', 'Settings', 'Settings nav item has correct text');
test.assertEquals(this.getElementAttribute('#main-menu li.settings a', 'href'), '/ghost/settings/', 'Settings href is correct');
test.assertEval(function testSettingsIsActive() {
return document.querySelector('#main-menu li.settings').classList.contains('active');
}, 'Settings nav item is marked active');
});
casper.then(function testUserMenuNotVisible() {
test.assertExists('#usermenu', 'User menu nav item exists');
test.assertNotVisible('#usermenu ul.overlay', 'User menu should not be visible');
});
casper.thenClick('#usermenu a');
casper.waitForSelector('#usermenu ul.overlay', function then() {
test.assertVisible('#usermenu ul.overlay', 'User menu should be visible');
test.assertExists('#usermenu li.usermenu-profile a', 'Profile menu item exists');
test.assertSelectorHasText('#usermenu li.usermenu-profile a', 'Your Profile', 'Profile menu item has correct text');
test.assertEquals(this.getElementAttribute('#usermenu li.usermenu-profile a', 'href'), '/ghost/settings/user/', 'Profile href is correct');
test.assertExists('#usermenu li.usermenu-help a', 'Help menu item exists');
test.assertSelectorHasText('#usermenu li.usermenu-help a', 'Help / Support', 'Help menu item has correct text');
test.assertEquals(this.getElementAttribute('#usermenu li.usermenu-help a', 'href'), 'http://ghost.org/forum/', 'Help href is correct');
test.assertExists('#usermenu li.usermenu-signout a', 'Sign Out menu item exists');
test.assertSelectorHasText('#usermenu li.usermenu-signout a', 'Sign Out', 'Sign Out menu item has correct text');
test.assertEquals(this.getElementAttribute('#usermenu li.usermenu-signout a', 'href'), '/ghost/signout/', 'Sign Out href is correct');
});
});

View File

@ -1,20 +0,0 @@
/*globals describe, before, after, beforeEach, afterEach, it */
var testUtils = require('../../utils'),
should = require('should'),
_ = require('lodash'),
request = require('request');
describe('Unauthorized', function () {
it('can\'t retrieve posts', function (done) {
request.get(testUtils.API.getApiURL('posts/'), function (error, response, body) {
response.should.have.status(401);
should.not.exist(response.headers['x-cache-invalidate']);
response.should.be.json;
var jsonResponse = JSON.parse(body);
jsonResponse.should.exist;
testUtils.API.checkResponseValue(jsonResponse, ['error']);
done();
});
});
});

View File

@ -1,496 +0,0 @@
/*globals describe, before, after, beforeEach, afterEach, it */
var testUtils = require('../../utils'),
should = require('should'),
_ = require('lodash'),
request = require('request');
request = request.defaults({jar: true});
describe('Post API', function () {
var user = testUtils.DataGenerator.forModel.users[0],
csrfToken = '';
before(function (done) {
testUtils.clearData()
.then(function () {
return testUtils.initData();
})
.then(function () {
return testUtils.insertDefaultFixtures();
})
.then(function () {
request.get(testUtils.API.getSigninURL(), function (error, response, body) {
response.should.have.status(200);
var pattern_meta = /<meta.*?name="csrf-param".*?content="(.*?)".*?>/i;
pattern_meta.should.exist;
csrfToken = body.match(pattern_meta)[1];
setTimeout((function () {
request.post({uri: testUtils.API.getSigninURL(),
headers: {'X-CSRF-Token': csrfToken}}, function (error, response, body) {
response.should.have.status(200);
request.get(testUtils.API.getAdminURL(), function (error, response, body) {
response.should.have.status(200);
csrfToken = body.match(pattern_meta)[1];
done();
});
}).form({email: user.email, password: user.password});
}), 2000);
});
}, done);
});
// ## Browse
describe('Browse', function () {
it('retrieves all published posts only by default', function (done) {
request.get(testUtils.API.getApiURL('posts/'), function (error, response, body) {
response.should.have.status(200);
should.not.exist(response.headers['x-cache-invalidate']);
response.should.be.json;
var jsonResponse = JSON.parse(body);
jsonResponse.posts.should.exist;
testUtils.API.checkResponse(jsonResponse, 'posts');
jsonResponse.posts.should.have.length(5);
testUtils.API.checkResponse(jsonResponse.posts[0], 'post');
done();
});
});
it('can retrieve all published posts and pages', function (done) {
request.get(testUtils.API.getApiURL('posts/?staticPages=all'), function (error, response, body) {
response.should.have.status(200);
should.not.exist(response.headers['x-cache-invalidate']);
response.should.be.json;
var jsonResponse = JSON.parse(body);
jsonResponse.posts.should.exist;
testUtils.API.checkResponse(jsonResponse, 'posts');
jsonResponse.posts.should.have.length(6);
testUtils.API.checkResponse(jsonResponse.posts[0], 'post');
done();
});
});
// Test bits of the API we don't use in the app yet to ensure the API behaves properly
it('can retrieve all status posts and pages', function (done) {
request.get(testUtils.API.getApiURL('posts/?staticPages=all&status=all'), function (error, response, body) {
response.should.have.status(200);
should.not.exist(response.headers['x-cache-invalidate']);
response.should.be.json;
var jsonResponse = JSON.parse(body);
jsonResponse.posts.should.exist;
testUtils.API.checkResponse(jsonResponse, 'posts');
jsonResponse.posts.should.have.length(8);
testUtils.API.checkResponse(jsonResponse.posts[0], 'post');
done();
});
});
it('can retrieve just published pages', function (done) {
request.get(testUtils.API.getApiURL('posts/?staticPages=true'), function (error, response, body) {
response.should.have.status(200);
should.not.exist(response.headers['x-cache-invalidate']);
response.should.be.json;
var jsonResponse = JSON.parse(body);
jsonResponse.posts.should.exist;
testUtils.API.checkResponse(jsonResponse, 'posts');
jsonResponse.posts.should.have.length(1);
testUtils.API.checkResponse(jsonResponse.posts[0], 'post');
done();
});
});
it('can retrieve just draft posts', function (done) {
request.get(testUtils.API.getApiURL('posts/?status=draft'), function (error, response, body) {
response.should.have.status(200);
should.not.exist(response.headers['x-cache-invalidate']);
response.should.be.json;
var jsonResponse = JSON.parse(body);
jsonResponse.posts.should.exist;
testUtils.API.checkResponse(jsonResponse, 'posts');
jsonResponse.posts.should.have.length(1);
testUtils.API.checkResponse(jsonResponse.posts[0], 'post');
done();
});
});
});
// ## Read
describe('Read', function () {
it('can retrieve a post', function (done) {
request.get(testUtils.API.getApiURL('posts/1/'), function (error, response, body) {
response.should.have.status(200);
should.not.exist(response.headers['x-cache-invalidate']);
response.should.be.json;
var jsonResponse = JSON.parse(body);
jsonResponse.should.exist;
testUtils.API.checkResponse(jsonResponse, 'post');
jsonResponse.page.should.eql(0);
done();
});
});
it('can retrieve a static page', function (done) {
request.get(testUtils.API.getApiURL('posts/7/'), function (error, response, body) {
response.should.have.status(200);
should.not.exist(response.headers['x-cache-invalidate']);
response.should.be.json;
var jsonResponse = JSON.parse(body);
jsonResponse.should.exist;
testUtils.API.checkResponse(jsonResponse, 'post');
jsonResponse.page.should.eql(1);
done();
});
});
it('can\'t retrieve non existent post', function (done) {
request.get(testUtils.API.getApiURL('posts/99/'), function (error, response, body) {
response.should.have.status(404);
should.not.exist(response.headers['x-cache-invalidate']);
response.should.be.json;
var jsonResponse = JSON.parse(body);
jsonResponse.should.exist;
testUtils.API.checkResponseValue(jsonResponse, ['error']);
done();
});
});
it('can\'t retrieve a draft post', function (done) {
request.get(testUtils.API.getApiURL('posts/5/'), function (error, response, body) {
response.should.have.status(404);
should.not.exist(response.headers['x-cache-invalidate']);
response.should.be.json;
var jsonResponse = JSON.parse(body);
jsonResponse.should.exist;
testUtils.API.checkResponseValue(jsonResponse, ['error']);
done();
});
});
it('can\'t retrieve a draft page', function (done) {
request.get(testUtils.API.getApiURL('posts/8/'), function (error, response, body) {
response.should.have.status(404);
should.not.exist(response.headers['x-cache-invalidate']);
response.should.be.json;
var jsonResponse = JSON.parse(body);
jsonResponse.should.exist;
testUtils.API.checkResponseValue(jsonResponse, ['error']);
done();
});
});
});
// ## Add
describe('Add', function () {
it('can create a new draft, publish post, update post', function (done) {
var newTitle = 'My Post',
changedTitle = 'My Post changed',
publishedState = 'published',
newPost = {status: 'draft', title: newTitle, markdown: 'my post'};
request.post({uri: testUtils.API.getApiURL('posts/'),
headers: {'X-CSRF-Token': csrfToken},
json: newPost}, function (error, response, draftPost) {
response.should.have.status(200);
response.should.be.json;
draftPost.should.exist;
draftPost.title.should.eql(newTitle);
draftPost.status = publishedState;
testUtils.API.checkResponse(draftPost, 'post');
request.put({uri: testUtils.API.getApiURL('posts/' + draftPost.id + '/'),
headers: {'X-CSRF-Token': csrfToken},
json: draftPost}, function (error, response, publishedPost) {
response.should.have.status(200);
response.headers['x-cache-invalidate'].should.eql('/, /page/*, /rss/, /rss/*, /' + publishedPost.slug + '/');
response.should.be.json;
publishedPost.should.exist;
publishedPost.title.should.eql(newTitle);
publishedPost.status.should.eql(publishedState);
testUtils.API.checkResponse(publishedPost, 'post');
request.put({uri: testUtils.API.getApiURL('posts/' + publishedPost.id + '/'),
headers: {'X-CSRF-Token': csrfToken},
json: publishedPost}, function (error, response, updatedPost) {
response.should.have.status(200);
response.headers['x-cache-invalidate'].should.eql('/, /page/*, /rss/, /rss/*, /' + updatedPost.slug + '/');
response.should.be.json;
updatedPost.should.exist;
updatedPost.title.should.eql(newTitle);
testUtils.API.checkResponse(updatedPost, 'post');
done();
});
});
});
});
});
// ## edit
describe('Edit', function () {
it('can edit a post', function (done) {
request.get(testUtils.API.getApiURL('posts/1/'), function (error, response, body) {
var jsonResponse = JSON.parse(body),
changedValue = 'My new Title';
jsonResponse.should.exist;
jsonResponse.title = changedValue;
request.put({uri: testUtils.API.getApiURL('posts/1/'),
headers: {'X-CSRF-Token': csrfToken},
json: jsonResponse}, function (error, response, putBody) {
response.should.have.status(200);
response.headers['x-cache-invalidate'].should.eql('/, /page/*, /rss/, /rss/*, /' + putBody.slug + '/');
response.should.be.json;
putBody.should.exist;
putBody.title.should.eql(changedValue);
testUtils.API.checkResponse(putBody, 'post');
done();
});
});
});
it('can change a post to a static page', function (done) {
request.get(testUtils.API.getApiURL('posts/1/'), function (error, response, body) {
var jsonResponse = JSON.parse(body),
changedValue = true;
jsonResponse.should.exist;
jsonResponse.page.should.eql(0);
jsonResponse.page = changedValue;
request.put({uri: testUtils.API.getApiURL('posts/1/'),
headers: {'X-CSRF-Token': csrfToken},
json: jsonResponse}, function (error, response, putBody) {
response.should.have.status(200);
response.headers['x-cache-invalidate'].should.eql('/, /page/*, /rss/, /rss/*, /' + putBody.slug + '/');
response.should.be.json;
putBody.should.exist;
putBody.page.should.eql(changedValue);
testUtils.API.checkResponse(putBody, 'post');
done();
});
});
});
it('can change a static page to a post', function (done) {
request.get(testUtils.API.getApiURL('posts/7/'), function (error, response, body) {
var jsonResponse = JSON.parse(body),
changedValue = false;
jsonResponse.should.exist;
jsonResponse.page.should.eql(1);
jsonResponse.page = changedValue;
request.put({uri: testUtils.API.getApiURL('posts/1/'),
headers: {'X-CSRF-Token': csrfToken},
json: jsonResponse}, function (error, response, putBody) {
response.should.have.status(200);
response.headers['x-cache-invalidate'].should.eql('/, /page/*, /rss/, /rss/*, /' + putBody.slug + '/');
response.should.be.json;
putBody.should.exist;
putBody.page.should.eql(changedValue);
testUtils.API.checkResponse(putBody, 'post');
done();
});
});
});
it('can\'t edit a post with invalid CSRF token', function (done) {
request.get(testUtils.API.getApiURL('posts/1/'), function (error, response, body) {
var jsonResponse = JSON.parse(body);
request.put({uri: testUtils.API.getApiURL('posts/1/'),
headers: {'X-CSRF-Token': 'invalid-token'},
json: jsonResponse}, function (error, response, putBody) {
response.should.have.status(403);
done();
});
});
});
it('published_at = null', function (done) {
request.get(testUtils.API.getApiURL('posts/1/'), function (error, response, body) {
var jsonResponse = JSON.parse(body),
changedValue = 'My new Title';
jsonResponse.should.exist;
jsonResponse.title = changedValue;
jsonResponse.published_at = null;
request.put({uri: testUtils.API.getApiURL('posts/1/'),
headers: {'X-CSRF-Token': csrfToken},
json: jsonResponse}, function (error, response, putBody) {
response.should.have.status(200);
response.headers['x-cache-invalidate'].should.eql('/, /page/*, /rss/, /rss/*, /' + putBody.slug + '/');
response.should.be.json;
putBody.should.exist;
putBody.title.should.eql(changedValue);
if (_.isEmpty(putBody.published_at)) {
should.fail('null', 'valid date', 'publish_at should not be empty');
done();
}
testUtils.API.checkResponse(putBody, 'post');
done();
});
});
});
});
// ## delete
describe('Delete', function () {
it('can\'t edit non existent post', function (done) {
request.get(testUtils.API.getApiURL('posts/1/'), function (error, response, body) {
var jsonResponse = JSON.parse(body),
changedValue = 'My new Title';
jsonResponse.title.exist;
jsonResponse.testvalue = changedValue;
jsonResponse.id = 99;
request.put({uri: testUtils.API.getApiURL('posts/99/'),
headers: {'X-CSRF-Token': csrfToken},
json: jsonResponse}, function (error, response, putBody) {
response.should.have.status(404);
should.not.exist(response.headers['x-cache-invalidate']);
response.should.be.json;
testUtils.API.checkResponseValue(putBody, ['error']);
done();
});
});
});
it('can delete a post', function (done) {
var deletePostId = 1;
request.del({uri: testUtils.API.getApiURL('posts/' + deletePostId + '/'),
headers: {'X-CSRF-Token': csrfToken}}, function (error, response, body) {
response.should.have.status(200);
response.should.be.json;
var jsonResponse = JSON.parse(body);
jsonResponse.should.exist;
response.headers['x-cache-invalidate'].should.eql('/, /page/*, /rss/, /rss/*, /' + jsonResponse.slug + '/');
testUtils.API.checkResponse(jsonResponse, 'post');
jsonResponse.id.should.eql(deletePostId);
done();
});
});
it('can\'t delete a non existent post', function (done) {
request.del({uri: testUtils.API.getApiURL('posts/99/'),
headers: {'X-CSRF-Token': csrfToken}}, function (error, response, body) {
response.should.have.status(404);
should.not.exist(response.headers['x-cache-invalidate']);
response.should.be.json;
var jsonResponse = JSON.parse(body);
jsonResponse.should.exist;
testUtils.API.checkResponseValue(jsonResponse, ['error']);
done();
});
});
it('can delete a new draft', function (done) {
var newTitle = 'My Post',
publishedState = 'draft',
newPost = {status: publishedState, title: newTitle, markdown: 'my post'};
request.post({uri: testUtils.API.getApiURL('posts/'),
headers: {'X-CSRF-Token': csrfToken},
json: newPost}, function (error, response, draftPost) {
response.should.have.status(200);
response.should.be.json;
draftPost.should.exist;
draftPost.title.should.eql(newTitle);
draftPost.status = publishedState;
testUtils.API.checkResponse(draftPost, 'post');
request.del({uri: testUtils.API.getApiURL('posts/' + draftPost.id + '/'),
headers: {'X-CSRF-Token': csrfToken}}, function (error, response, body) {
response.should.have.status(200);
response.should.be.json;
var jsonResponse = JSON.parse(body);
jsonResponse.should.exist;
testUtils.API.checkResponse(jsonResponse, 'post');
done();
});
});
});
});
describe('Dated Permalinks', function () {
before(function (done) {
request.get(testUtils.API.getApiURL('settings'), function (error, response, body) {
if (error) { done(error); }
var jsonResponse = JSON.parse(body);
jsonResponse.permalinks = '/:year/:month/:day/:slug/';
request.put({
uri: testUtils.API.getApiURL('settings/'),
headers: {'X-CSRF-Token': csrfToken},
json: jsonResponse
}, function (error, response, putBody) {
if (error) { done(error); }
done();
});
});
});
after(function (done) {
request.get(testUtils.API.getApiURL('settings'), function (error, response, body) {
if (error) { done(error); }
var jsonResponse = JSON.parse(body);
jsonResponse.permalinks = '/:slug/';
request.put({
uri: testUtils.API.getApiURL('settings/'),
headers: {'X-CSRF-Token': csrfToken},
json: jsonResponse
}, function (error, response, putBody) {
if (error) { done(error); }
done();
});
});
});
it('Can read a post', function (done) {
// nothing should have changed here
request.get(testUtils.API.getApiURL('posts/2/'), function (error, response, body) {
if (error) { done(error); }
response.should.have.status(200);
should.not.exist(response.headers['x-cache-invalidate']);
response.should.be.json;
var jsonResponse = JSON.parse(body);
jsonResponse.should.exist;
testUtils.API.checkResponse(jsonResponse, 'post');
jsonResponse.slug.should.not.match(/^\/[0-9]{4}\/[0-9]{2}\/[0-9]{2}/);
jsonResponse.page.should.eql(0);
done();
});
});
it('Can edit a post', function (done) {
request.get(testUtils.API.getApiURL('posts/2/'), function (error, response, body) {
if (error) { done(error); }
var jsonResponse = JSON.parse(body),
changedValue = 'My new Title';
jsonResponse.should.exist;
jsonResponse.title = changedValue;
request.put({
uri: testUtils.API.getApiURL('posts/2/'),
headers: {'X-CSRF-Token': csrfToken},
json: jsonResponse
}, function (error, response, putBody) {
if (error) { done(error); }
var today = new Date(),
dd = ("0" + today.getDate()).slice(-2),
mm = ("0" + (today.getMonth() + 1)).slice(-2),
yyyy = today.getFullYear(),
postLink = '/' + yyyy + '/' + mm + '/' + dd + '/' + putBody.slug + '/';
response.should.have.status(200);
response.headers['x-cache-invalidate'].should.eql('/, /page/*, /rss/, /rss/*, ' + postLink);
response.should.be.json;
putBody.should.exist;
putBody.title.should.eql(changedValue);
testUtils.API.checkResponse(putBody, 'post');
done();
});
});
});
});
});

View File

@ -1,137 +0,0 @@
/*globals describe, before, beforeEach, afterEach, it */
var testUtils = require('../../utils'),
should = require('should'),
_ = require('lodash'),
request = require('request');
request = request.defaults({jar:true})
describe('Settings API', function () {
var user = testUtils.DataGenerator.forModel.users[0],
csrfToken = '';
before(function (done) {
testUtils.clearData()
.then(function () {
return testUtils.initData();
})
.then(function () {
return testUtils.insertDefaultFixtures();
})
.then(function () {
request.get(testUtils.API.getSigninURL(), function (error, response, body) {
response.should.have.status(200);
var pattern_meta = /<meta.*?name="csrf-param".*?content="(.*?)".*?>/i;
pattern_meta.should.exist;
csrfToken = body.match(pattern_meta)[1];
setTimeout((function () {
request.post({uri: testUtils.API.getSigninURL(),
headers: {'X-CSRF-Token': csrfToken}}, function (error, response, body) {
response.should.have.status(200);
request.get(testUtils.API.getAdminURL(), function (error, response, body) {
response.should.have.status(200);
csrfToken = body.match(pattern_meta)[1];
done();
});
}).form({email: user.email, password: user.password});
}), 2000);
});
}, done);
});
// TODO: currently includes values of type=core
it('can retrieve all settings', function (done) {
request.get(testUtils.API.getApiURL('settings/'), function (error, response, body) {
response.should.have.status(200);
should.not.exist(response.headers['x-cache-invalidate']);
response.should.be.json;
var jsonResponse = JSON.parse(body);
jsonResponse.should.exist;
testUtils.API.checkResponse(jsonResponse, 'settings');
done();
});
});
it('can retrieve a setting', function (done) {
request.get(testUtils.API.getApiURL('settings/title/'), function (error, response, body) {
response.should.have.status(200);
should.not.exist(response.headers['x-cache-invalidate']);
response.should.be.json;
var jsonResponse = JSON.parse(body);
jsonResponse.should.exist;
testUtils.API.checkResponseValue(jsonResponse, ['key','value']);
jsonResponse.key.should.eql('title');
done();
});
});
it('can\'t retrieve non existent setting', function (done) {
request.get(testUtils.API.getApiURL('settings/testsetting/'), function (error, response, body) {
response.should.have.status(404);
should.not.exist(response.headers['x-cache-invalidate']);
response.should.be.json;
var jsonResponse = JSON.parse(body);
jsonResponse.should.exist;
testUtils.API.checkResponseValue(jsonResponse, ['error']);
done();
});
});
it('can edit settings', function (done) {
request.get(testUtils.API.getApiURL('settings'), function (error, response, body) {
var jsonResponse = JSON.parse(body),
changedValue = 'Ghost changed';
jsonResponse.should.exist;
jsonResponse.title = changedValue;
request.put({uri: testUtils.API.getApiURL('settings/'),
headers: {'X-CSRF-Token': csrfToken},
json: jsonResponse}, function (error, response, putBody) {
response.should.have.status(200);
response.headers['x-cache-invalidate'].should.eql('/*');
response.should.be.json;
putBody.should.exist;
putBody.title.should.eql(changedValue);
testUtils.API.checkResponse(putBody, 'settings');
done();
});
});
});
it('can\'t edit settings with invalid CSRF token', function (done) {
request.get(testUtils.API.getApiURL('settings'), function (error, response, body) {
var jsonResponse = JSON.parse(body),
changedValue = 'Ghost changed';
jsonResponse.should.exist;
jsonResponse.title = changedValue;
request.put({uri: testUtils.API.getApiURL('settings/'),
headers: {'X-CSRF-Token': 'invalid-token'},
json: jsonResponse}, function (error, response, putBody) {
response.should.have.status(403);
done();
});
});
});
it('can\'t edit non existent setting', function (done) {
request.get(testUtils.API.getApiURL('settings'), function (error, response, body) {
var jsonResponse = JSON.parse(body),
newValue = 'new value';
jsonResponse.should.exist;
jsonResponse.testvalue = newValue;
request.put({uri: testUtils.API.getApiURL('settings/'),
headers: {'X-CSRF-Token': csrfToken},
json: jsonResponse}, function (error, response, putBody) {
response.should.have.status(404);
should.not.exist(response.headers['x-cache-invalidate']);
response.should.be.json;
testUtils.API.checkResponseValue(putBody, ['error']);
done();
});
});
});
});

View File

@ -1,55 +0,0 @@
/*globals describe, before, beforeEach, afterEach, it */
var testUtils = require('../../utils'),
should = require('should'),
_ = require('lodash'),
request = require('request');
request = request.defaults({jar:true})
describe('Tag API', function () {
var user = testUtils.DataGenerator.forModel.users[0],
csrfToken = '';
before(function (done) {
testUtils.clearData()
.then(function () {
return testUtils.initData();
})
.then(function () {
return testUtils.insertDefaultFixtures();
})
.then(function () {
request.get(testUtils.API.getSigninURL(), function (error, response, body) {
response.should.have.status(200);
var pattern_meta = /<meta.*?name="csrf-param".*?content="(.*?)".*?>/i;
pattern_meta.should.exist;
csrfToken = body.match(pattern_meta)[1];
setTimeout((function () {
request.post({uri: testUtils.API.getSigninURL(),
headers: {'X-CSRF-Token': csrfToken}}, function (error, response, body) {
response.should.have.status(200);
request.get(testUtils.API.getAdminURL(), function (error, response, body) {
response.should.have.status(200);
csrfToken = body.match(pattern_meta)[1];
done();
});
}).form({email: user.email, password: user.password});
}), 2000);
});
}, done);
});
it('can retrieve all tags', function (done) {
request.get(testUtils.API.getApiURL('tags/'), function (error, response, body) {
response.should.have.status(200);
should.not.exist(response.headers['x-cache-invalidate']);
response.should.be.json;
var jsonResponse = JSON.parse(body);
jsonResponse.should.exist;
jsonResponse.should.have.length(6);
testUtils.API.checkResponse(jsonResponse[0], 'tag');
done();
});
});
});

View File

@ -1,119 +0,0 @@
/*globals describe, before, beforeEach, afterEach, it */
var testUtils = require('../../utils'),
should = require('should'),
_ = require('lodash'),
request = require('request');
request = request.defaults({jar:true})
describe('User API', function () {
var user = testUtils.DataGenerator.forModel.users[0],
csrfToken = '';
before(function (done) {
testUtils.clearData()
.then(function () {
return testUtils.initData();
})
.then(function () {
return testUtils.insertDefaultFixtures();
})
.then(function () {
request.get(testUtils.API.getSigninURL(), function (error, response, body) {
response.should.have.status(200);
var pattern_meta = /<meta.*?name="csrf-param".*?content="(.*?)".*?>/i;
pattern_meta.should.exist;
csrfToken = body.match(pattern_meta)[1];
setTimeout((function () {
request.post({uri: testUtils.API.getSigninURL(),
headers: {'X-CSRF-Token': csrfToken}}, function (error, response, body) {
response.should.have.status(200);
request.get(testUtils.API.getAdminURL(), function (error, response, body) {
response.should.have.status(200);
csrfToken = body.match(pattern_meta)[1];
done();
});
}).form({email: user.email, password: user.password});
}), 2000);
});
}, done);
});
it('can retrieve all users', function (done) {
request.get(testUtils.API.getApiURL('users/'), function (error, response, body) {
response.should.have.status(200);
should.not.exist(response.headers['x-cache-invalidate']);
response.should.be.json;
var jsonResponse = JSON.parse(body);
jsonResponse[0].should.exist;
testUtils.API.checkResponse(jsonResponse[0], 'user');
done();
});
});
it('can retrieve a user', function (done) {
request.get(testUtils.API.getApiURL('users/me/'), function (error, response, body) {
response.should.have.status(200);
should.not.exist(response.headers['x-cache-invalidate']);
response.should.be.json;
var jsonResponse = JSON.parse(body);
jsonResponse.should.exist;
testUtils.API.checkResponse(jsonResponse, 'user');
done();
});
});
it('can\'t retrieve non existent user', function (done) {
request.get(testUtils.API.getApiURL('users/99/'), function (error, response, body) {
response.should.have.status(404);
should.not.exist(response.headers['x-cache-invalidate']);
response.should.be.json;
var jsonResponse = JSON.parse(body);
jsonResponse.should.exist;
testUtils.API.checkResponseValue(jsonResponse, ['error']);
done();
});
});
it('can edit a user', function (done) {
request.get(testUtils.API.getApiURL('users/me/'), function (error, response, body) {
var jsonResponse = JSON.parse(body),
changedValue = 'joe-bloggs.ghost.org';
jsonResponse.should.exist;
jsonResponse.website = changedValue;
request.put({uri: testUtils.API.getApiURL('users/me/'),
headers: {'X-CSRF-Token': csrfToken},
json: jsonResponse}, function (error, response, putBody) {
response.should.have.status(200);
response.headers['x-cache-invalidate'].should.eql('/*');
response.should.be.json;
putBody.should.exist;
putBody.website.should.eql(changedValue);
testUtils.API.checkResponse(putBody, 'user');
done();
});
});
});
it('can\'t edit a user with invalid CSRF token', function (done) {
request.get(testUtils.API.getApiURL('users/me/'), function (error, response, body) {
var jsonResponse = JSON.parse(body),
changedValue = 'joe-bloggs.ghost.org';
jsonResponse.should.exist;
jsonResponse.website = changedValue;
request.put({uri: testUtils.API.getApiURL('users/me/'),
headers: {'X-CSRF-Token': 'invalid-token'},
json: jsonResponse}, function (error, response, putBody) {
response.should.have.status(403);
done();
});
});
});
});

View File

@ -49,12 +49,16 @@ var DEBUG = false, // TOGGLE THIS TO GET MORE SCREENSHOTS
casper.writeContentToCodeMirror = function (content) {
var lines = content.split("\n");
casper.each(lines, function (self, line) {
self.sendKeys('.CodeMirror-wrap textarea', line, {keepFocus: true});
self.sendKeys('.CodeMirror-wrap textarea', casper.page.event.key.Enter, {keepFocus: true});
});
casper.waitForSelector('.CodeMirror-wrap textarea', function onSuccess() {
casper.each(lines, function (self, line) {
self.sendKeys('.CodeMirror-wrap textarea', line, {keepFocus: true});
self.sendKeys('.CodeMirror-wrap textarea', casper.page.event.key.Enter, {keepFocus: true});
});
return this;
return this;
}, function onTimeout() {
casper.test.fail('CodeMirror was not found.');
}, 2000);
};
casper.waitForOpaque = function (classname, then, timeout) {

View File

@ -10,7 +10,7 @@ CasperTest.begin('Ensure that RSS is available', 11, function suite(test) {
siteDescription = '<description><![CDATA[Just a blogging platform.]]></description>',
siteUrl = '<link>http://127.0.0.1:2369/</link>',
postTitle = '<![CDATA[Welcome to Ghost]]>',
postStart = '<description><![CDATA[<p>Youre live!',
postStart = '<description><![CDATA[<p>You\'re live!',
postEnd = 'you think :)</p>]]></description>',
postLink = '<link>http://127.0.0.1:2369/welcome-to-ghost/</link>',
postCreator = '<dc:creator><![CDATA[Test User]]>';
@ -29,17 +29,17 @@ CasperTest.begin('Ensure that RSS is available', 11, function suite(test) {
});
}, false);
CasperTest.begin('Ensure that author element is not included. Only dc:creator', 3, function suite(test) {
CasperTest.begin('Ensure that author element is not included. Only dc:creator', 3, function suite(test) {
CasperTest.Routines.togglePermalinks.run('off');
casper.thenOpen(url + 'rss/', function (response) {
casper.thenOpen(url + 'rss/', function (response) {
var content = this.getPageContent(),
author = '<author>',
postCreator = '<dc:creator><![CDATA[Test User]]>';
test.assertEqual(response.status, 200, 'Response status should be 200.');
test.assert(content.indexOf(author) < 0, 'Author element should not be included');
test.assert(content.indexOf(postCreator) >= 0, 'Welcome post should have Test User as the creator.');
});
test.assert(content.indexOf(postCreator) >= 0, 'Welcome post should have Test User as the creator.');
});
}, false);
CasperTest.begin('Ensures dated permalinks works with RSS', 2, function suite(test) {

View File

@ -6,11 +6,13 @@
// But then again testing real code, rather than mock code, might be more useful...
var request = require('supertest'),
express = require('express'),
should = require('should'),
moment = require('moment'),
testUtils = require('../../utils'),
config = require('../../../server/config'),
ghost = require('../../../../core'),
httpServer,
ONE_HOUR_S = 60 * 60,
ONE_YEAR_S = 365 * 24 * ONE_HOUR_S,
@ -37,16 +39,77 @@ describe('Admin Routing', function () {
};
}
before(function (done) {
testUtils.clearData().then(function () {
// we initialise data, but not a user.
return testUtils.initData();
}).then(function () {
done();
}, done);
function doEndNoAuth(done) {
return function (err, res) {
if (err) {
return done(err);
}
// Setup the request object with the correct URL
request = request(config().url);
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);
done();
};
}
before(function (done) {
var app = express();
ghost({app: app}).then(function (_httpServer) {
// Setup the request object with the ghost express app
httpServer = _httpServer;
request = request(app);
testUtils.clearData().then(function () {
// we initialise data, but not a user. No user should be required for navigating the frontend
return testUtils.initData();
}).then(function () {
done();
}, done);
}).otherwise(function (e) {
console.log('Ghost Error: ', e);
console.log(e.stack);
});
});
after(function () {
httpServer.close();
});
describe('Legacy Redirects', function () {
it('should redirect /logout/ to /ghost/signout/', function (done) {
request.get('/logout/')
.expect('Location', '/ghost/signout/')
.expect('Cache-Control', cacheRules.year)
.expect(301)
.end(doEndNoAuth(done));
});
it('should redirect /signout/ to /ghost/signout/', function (done) {
request.get('/signout/')
.expect('Location', '/ghost/signout/')
.expect('Cache-Control', cacheRules.year)
.expect(301)
.end(doEndNoAuth(done));
});
it('should redirect /signin/ to /ghost/signin/', function (done) {
request.get('/signin/')
.expect('Location', '/ghost/signin/')
.expect('Cache-Control', cacheRules.year)
.expect(301)
.end(doEndNoAuth(done));
});
it('should redirect /signup/ to /ghost/signup/', function (done) {
request.get('/signup/')
.expect('Location', '/ghost/signup/')
.expect('Cache-Control', cacheRules.year)
.expect(301)
.end(doEndNoAuth(done));
});
});
describe('Ghost Admin Signup', function () {

View File

@ -0,0 +1,66 @@
/*global describe, it, before, after */
// # Api Route tests
// As it stands, these tests depend on the database, and as such are integration tests.
// Mocking out the models to not touch the DB would turn these into unit tests, and should probably be done in future,
// But then again testing real code, rather than mock code, might be more useful...
var supertest = require('supertest'),
express = require('express'),
should = require('should'),
_ = require('lodash'),
testUtils = require('../../../utils'),
ghost = require('../../../../../core'),
httpServer,
request,
agent;
describe('Unauthorized', function () {
before(function (done) {
var app = express();
ghost({app: app}).then(function (_httpServer) {
httpServer = _httpServer;
// request = supertest(app);
request = supertest.agent(app);
testUtils.clearData().then(function () {
// we initialise data, but not a user.
return testUtils.initData();
}).then(function () {
done();
}, done);
});
});
after(function () {
httpServer.close();
});
describe('Unauthorized', function () {
it('can\'t retrieve posts', function (done) {
request.get(testUtils.API.getApiQuery('posts/'))
.expect(401)
.end(function firstRequest(err, res) {
if (err) {
return done(err);
}
should.not.exist(res.headers['x-cache-invalidate']);
res.should.be.json;
var jsonResponse = res.body;
jsonResponse.should.exist;
testUtils.API.checkResponseValue(jsonResponse, ['error']);
done();
});
});
});
});

View File

@ -0,0 +1,729 @@
/*global describe, it, before, after */
var supertest = require('supertest'),
express = require('express'),
should = require('should'),
_ = require('lodash'),
testUtils = require('../../../utils'),
ghost = require('../../../../../core'),
httpServer,
request,
agent;
describe('Post API', function () {
var user = testUtils.DataGenerator.forModel.users[0],
csrfToken = '';
before(function (done) {
var app = express();
ghost({app: app}).then(function (_httpServer) {
httpServer = _httpServer;
request = supertest.agent(app);
testUtils.clearData()
.then(function () {
return testUtils.initData();
})
.then(function () {
return testUtils.insertDefaultFixtures();
})
.then(function () {
request.get('/ghost/signin/')
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
var pattern_meta = /<meta.*?name="csrf-param".*?content="(.*?)".*?>/i;
pattern_meta.should.exist;
csrfToken = res.text.match(pattern_meta)[1];
setTimeout(function () {
request.post('/ghost/signin/')
.set('X-CSRF-Token', csrfToken)
.send({email: user.email, password: user.password})
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
request.saveCookies(res);
request.get('/ghost/')
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
csrfToken = res.text.match(pattern_meta)[1];
done();
});
});
}, 2000);
});
}, done);
}).otherwise(function (e) {
console.log('Ghost Error: ', e);
console.log(e.stack);
});
});
after(function () {
httpServer.close();
});
describe('Browse', function () {
it('retrieves all published posts only by default', function (done) {
request.get(testUtils.API.getApiQuery('posts/'))
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
should.not.exist(res.headers['x-cache-invalidate']);
res.should.be.json;
var jsonResponse = res.body;
jsonResponse.posts.should.exist;
testUtils.API.checkResponse(jsonResponse, 'posts');
jsonResponse.posts.should.have.length(5);
testUtils.API.checkResponse(jsonResponse.posts[0], 'post');
done();
});
});
it('can retrieve all published posts and pages', function (done) {
request.get(testUtils.API.getApiQuery('posts/?staticPages=all'))
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
should.not.exist(res.headers['x-cache-invalidate']);
res.should.be.json;
var jsonResponse = res.body;
jsonResponse.posts.should.exist;
testUtils.API.checkResponse(jsonResponse, 'posts');
jsonResponse.posts.should.have.length(6);
testUtils.API.checkResponse(jsonResponse.posts[0], 'post');
done();
});
});
// Test bits of the API we don't use in the app yet to ensure the API behaves properly
it('can retrieve all status posts and pages', function (done) {
request.get(testUtils.API.getApiQuery('posts/?staticPages=all&status=all'))
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
should.not.exist(res.headers['x-cache-invalidate']);
res.should.be.json;
var jsonResponse = res.body;
jsonResponse.posts.should.exist;
testUtils.API.checkResponse(jsonResponse, 'posts');
jsonResponse.posts.should.have.length(8);
testUtils.API.checkResponse(jsonResponse.posts[0], 'post');
done();
});
});
it('can retrieve just published pages', function (done) {
request.get(testUtils.API.getApiQuery('posts/?staticPages=true'))
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
should.not.exist(res.headers['x-cache-invalidate']);
res.should.be.json;
var jsonResponse = res.body;
jsonResponse.posts.should.exist;
testUtils.API.checkResponse(jsonResponse, 'posts');
jsonResponse.posts.should.have.length(1);
testUtils.API.checkResponse(jsonResponse.posts[0], 'post');
done();
});
});
it('can retrieve just draft posts', function (done) {
request.get(testUtils.API.getApiQuery('posts/?status=draft'))
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
should.not.exist(res.headers['x-cache-invalidate']);
res.should.be.json;
var jsonResponse = res.body;
jsonResponse.posts.should.exist;
testUtils.API.checkResponse(jsonResponse, 'posts');
jsonResponse.posts.should.have.length(1);
testUtils.API.checkResponse(jsonResponse.posts[0], 'post');
done();
});
});
});
// ## Read
describe('Read', function () {
it('can retrieve a post', function (done) {
request.get(testUtils.API.getApiQuery('posts/1/'))
.end(function (err, res) {
if (err) {
return done(err);
}
res.should.have.status(200);
should.not.exist(res.headers['x-cache-invalidate']);
res.should.be.json;
var jsonResponse = res.body;
jsonResponse.should.exist;
testUtils.API.checkResponse(jsonResponse, 'post');
jsonResponse.page.should.eql(0);
done();
});
});
it('can retrieve a static page', function (done) {
request.get(testUtils.API.getApiQuery('posts/7/'))
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
should.not.exist(res.headers['x-cache-invalidate']);
res.should.be.json;
var jsonResponse = res.body;
jsonResponse.should.exist;
testUtils.API.checkResponse(jsonResponse, 'post');
jsonResponse.page.should.eql(1);
done();
});
});
it('can\'t retrieve non existent post', function (done) {
request.get(testUtils.API.getApiQuery('posts/99/'))
.expect(404)
.end(function (err, res) {
if (err) {
return done(err);
}
should.not.exist(res.headers['x-cache-invalidate']);
res.should.be.json;
var jsonResponse = res.body;
jsonResponse.should.exist;
testUtils.API.checkResponseValue(jsonResponse, ['error']);
done();
});
});
it('can\'t retrieve a draft post', function (done) {
request.get(testUtils.API.getApiQuery('posts/5/'))
.expect(404)
.end(function (err, res) {
if (err) {
return done(err);
}
should.not.exist(res.headers['x-cache-invalidate']);
res.should.be.json;
var jsonResponse = res.body;
jsonResponse.should.exist;
testUtils.API.checkResponseValue(jsonResponse, ['error']);
done();
});
});
it('can\'t retrieve a draft page', function (done) {
request.get(testUtils.API.getApiQuery('posts/8/'))
.expect(404)
.end(function (err, res) {
if (err) {
return done(err);
}
should.not.exist(res.headers['x-cache-invalidate']);
res.should.be.json;
var jsonResponse = res.body;
jsonResponse.should.exist;
testUtils.API.checkResponseValue(jsonResponse, ['error']);
done();
});
});
});
// ## Add
describe('Add', function () {
it('can create a new draft, publish post, update post', function (done) {
var newTitle = 'My Post',
changedTitle = 'My Post changed',
publishedState = 'published',
newPost = {status: 'draft', title: newTitle, markdown: 'my post'};
request.post(testUtils.API.getApiQuery('posts/'))
.set('X-CSRF-Token', csrfToken)
.send(newPost)
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
res.should.be.json;
var draftPost = res.body;
draftPost.should.exist;
draftPost.title.should.eql(newTitle);
draftPost.status = publishedState;
testUtils.API.checkResponse(draftPost, 'post');
request.put(testUtils.API.getApiQuery('posts/' + draftPost.id + '/'))
.set('X-CSRF-Token', csrfToken)
.send(draftPost)
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
var publishedPost = res.body;
res.headers['x-cache-invalidate'].should.eql('/, /page/*, /rss/, /rss/*, /tag/*, /' + publishedPost.slug + '/');
res.should.be.json;
publishedPost.should.exist;
publishedPost.title.should.eql(newTitle);
publishedPost.status.should.eql(publishedState);
testUtils.API.checkResponse(publishedPost, 'post');
request.put(testUtils.API.getApiQuery('posts/' + publishedPost.id + '/'))
.set('X-CSRF-Token', csrfToken)
.send(publishedPost)
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
var updatedPost = res.body;
res.headers['x-cache-invalidate'].should.eql('/, /page/*, /rss/, /rss/*, /tag/*, /' + updatedPost.slug + '/');
res.should.be.json;
updatedPost.should.exist;
updatedPost.title.should.eql(newTitle);
testUtils.API.checkResponse(updatedPost, 'post');
done();
});
});
});
});
});
// ## edit
describe('Edit', function () {
it('can edit a post', function (done) {
request.get(testUtils.API.getApiQuery('posts/1/'))
.end(function (err, res) {
if (err) {
return done(err);
}
var jsonResponse = res.body,
changedValue = 'My new Title';
jsonResponse.should.exist;
jsonResponse.title = changedValue;
request.put(testUtils.API.getApiQuery('posts/1/'))
.set('X-CSRF-Token', csrfToken)
.send(jsonResponse)
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
var putBody = res.body;
res.headers['x-cache-invalidate'].should.eql('/, /page/*, /rss/, /rss/*, /tag/*, /' + putBody.slug + '/');
res.should.be.json;
putBody.should.exist;
putBody.title.should.eql(changedValue);
testUtils.API.checkResponse(putBody, 'post');
done();
});
});
});
it('can change a post to a static page', function (done) {
request.get(testUtils.API.getApiQuery('posts/1/'))
.end(function (err, res) {
if (err) {
return done(err);
}
var jsonResponse = res.body,
changedValue = true;
jsonResponse.should.exist;
jsonResponse.page.should.eql(0);
jsonResponse.page = changedValue;
request.put(testUtils.API.getApiQuery('posts/1/'))
.set('X-CSRF-Token', csrfToken)
.send(jsonResponse)
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
var putBody = res.body;
res.headers['x-cache-invalidate'].should.eql('/, /page/*, /rss/, /rss/*, /tag/*, /' + putBody.slug + '/');
res.should.be.json;
putBody.should.exist;
putBody.page.should.eql(changedValue);
testUtils.API.checkResponse(putBody, 'post');
done();
});
});
});
it('can change a static page to a post', function (done) {
request.get(testUtils.API.getApiQuery('posts/7/'))
.end(function (err, res) {
if (err) {
return done(err);
}
var jsonResponse = res.body,
changedValue = false;
jsonResponse.should.exist;
jsonResponse.page.should.eql(1);
jsonResponse.page = changedValue;
request.put(testUtils.API.getApiQuery('posts/1/'))
.set('X-CSRF-Token', csrfToken)
.send(jsonResponse)
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
var putBody = res.body;
res.headers['x-cache-invalidate'].should.eql('/, /page/*, /rss/, /rss/*, /tag/*, /' + putBody.slug + '/');
res.should.be.json;
putBody.should.exist;
putBody.page.should.eql(changedValue);
testUtils.API.checkResponse(putBody, 'post');
done();
});
});
});
it('can\'t edit a post with invalid CSRF token', function (done) {
request.get(testUtils.API.getApiQuery('posts/1/'))
.end(function (err, res) {
if (err) {
return done(err);
}
var jsonResponse = res.body;
request.put(testUtils.API.getApiQuery('posts/1/'))
.set('X-CSRF-Token', 'invalid-token')
.send(jsonResponse)
.expect(403)
.end(function (err, res) {
if (err) {
return done(err);
}
done();
});
});
});
it('published_at = null', function (done) {
request.get(testUtils.API.getApiQuery('posts/1/'))
.end(function (err, res) {
if (err) {
return done(err);
}
var jsonResponse = res.body,
changedValue = 'My new Title';
jsonResponse.should.exist;
jsonResponse.title = changedValue;
jsonResponse.published_at = null;
request.put(testUtils.API.getApiQuery('posts/1/'))
.set('X-CSRF-Token', csrfToken)
.send(jsonResponse)
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
var putBody = res.body;
res.headers['x-cache-invalidate'].should.eql('/, /page/*, /rss/, /rss/*, /tag/*, /' + putBody.slug + '/');
res.should.be.json;
putBody.should.exist;
putBody.title.should.eql(changedValue);
if (_.isEmpty(putBody.published_at)) {
should.fail('null', 'valid date', 'publish_at should not be empty');
done();
}
testUtils.API.checkResponse(putBody, 'post');
done();
});
});
});
});
// ## delete
describe('Delete', function () {
it('can\'t edit non existent post', function (done) {
request.get(testUtils.API.getApiQuery('posts/1/'))
.end(function (err, res) {
if (err) {
return done(err);
}
var jsonResponse = res.body,
changedValue = 'My new Title';
jsonResponse.title.exist;
jsonResponse.testvalue = changedValue;
jsonResponse.id = 99;
request.put(testUtils.API.getApiQuery('posts/99/'))
.set('X-CSRF-Token', csrfToken)
.send(jsonResponse)
.expect(404)
.end(function (err, res) {
if (err) {
return done(err);
}
var putBody = res.body;
should.not.exist(res.headers['x-cache-invalidate']);
res.should.be.json;
testUtils.API.checkResponseValue(putBody, ['error']);
done();
});
});
});
it('can delete a post', function (done) {
var deletePostId = 1;
request.del(testUtils.API.getApiQuery('posts/' + deletePostId + '/'))
.set('X-CSRF-Token', csrfToken)
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
res.should.be.json;
var jsonResponse = res.body;
jsonResponse.should.exist;
res.headers['x-cache-invalidate'].should.eql('/, /page/*, /rss/, /rss/*, /tag/*, /' + jsonResponse.slug + '/');
testUtils.API.checkResponse(jsonResponse, 'post');
jsonResponse.id.should.eql(deletePostId);
done();
});
});
it('can\'t delete a non existent post', function (done) {
request.del(testUtils.API.getApiQuery('posts/99/'))
.set('X-CSRF-Token', csrfToken)
.expect(404)
.end(function (err, res) {
if (err) {
return done(err);
}
should.not.exist(res.headers['x-cache-invalidate']);
res.should.be.json;
var jsonResponse = res.body;
jsonResponse.should.exist;
testUtils.API.checkResponseValue(jsonResponse, ['error']);
done();
});
});
it('can delete a new draft', function (done) {
var newTitle = 'My Post',
publishedState = 'draft',
newPost = {status: publishedState, title: newTitle, markdown: 'my post'};
request.post(testUtils.API.getApiQuery('posts/'))
.set('X-CSRF-Token', csrfToken)
.send(newPost)
.expect(200)
.end(function (err ,res) {
if (err) {
return done(err);
}
var draftPost = res.body;
res.should.be.json;
draftPost.should.exist;
draftPost.title.should.eql(newTitle);
draftPost.status = publishedState;
testUtils.API.checkResponse(draftPost, 'post');
request.del(testUtils.API.getApiQuery('posts/' + draftPost.id + '/'))
.set('X-CSRF-Token', csrfToken)
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
res.should.be.json;
var jsonResponse = res.body
jsonResponse.should.exist;
testUtils.API.checkResponse(jsonResponse, 'post');
done();
});
});
});
});
describe('Dated Permalinks', function () {
before(function (done) {
request.get(testUtils.API.getApiQuery('settings/'))
.end(function (err, res) {
if (err) {
return done(err);
}
var jsonResponse = res.body;
jsonResponse.permalinks = '/:year/:month/:day/:slug/';
request.put(testUtils.API.getApiQuery('settings/'))
.set('X-CSRF-Token', csrfToken)
.send(jsonResponse)
.end(function (err, res) {
if (err) {
return done(err);
}
done();
});
});
});
after(function (done) {
request.get(testUtils.API.getApiQuery('settings/'))
.end(function (err, res) {
if (err) {
return done(err);
}
var jsonResponse = res.body;
jsonResponse.permalinks = '/:slug/';
request.put(testUtils.API.getApiQuery('settings/'))
.set('X-CSRF-Token', csrfToken)
.send(jsonResponse)
.end(function (err, res) {
if (err) {
return done(err);
}
done();
});
});
});
it('Can read a post', function (done) {
// nothing should have changed here
request.get(testUtils.API.getApiQuery('posts/2/'))
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
should.not.exist(res.headers['x-cache-invalidate']);
res.should.be.json;
var jsonResponse = res.body;
jsonResponse.should.exist;
testUtils.API.checkResponse(jsonResponse, 'post');
jsonResponse.slug.should.not.match(/^\/[0-9]{4}\/[0-9]{2}\/[0-9]{2}/);
jsonResponse.page.should.eql(0);
done();
});
});
it('Can edit a post', function (done) {
request.get(testUtils.API.getApiQuery('posts/2/'))
.end(function (err, res) {
if (err) {
return done(err);
}
var jsonResponse = res.body,
changedValue = 'My new Title';
jsonResponse.should.exist;
jsonResponse.title = changedValue;
request.put(testUtils.API.getApiQuery('posts/2/'))
.set('X-CSRF-Token', csrfToken)
.send(jsonResponse)
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
var putBody = res.body;
var today = new Date(),
dd = ("0" + today.getDate()).slice(-2),
mm = ("0" + (today.getMonth() + 1)).slice(-2),
yyyy = today.getFullYear(),
postLink = '/' + yyyy + '/' + mm + '/' + dd + '/' + putBody.slug + '/';
res.headers['x-cache-invalidate'].should.eql('/, /page/*, /rss/, /rss/*, /tag/*, ' + postLink);
res.should.be.json;
putBody.should.exist;
putBody.title.should.eql(changedValue);
testUtils.API.checkResponse(putBody, 'post');
done();
});
});
});
});
});

View File

@ -0,0 +1,229 @@
/*global describe, it, before, after */
var supertest = require('supertest'),
express = require('express'),
should = require('should'),
_ = require('lodash'),
testUtils = require('../../../utils'),
ghost = require('../../../../../core'),
httpServer,
request,
agent;
describe('Settings API', function () {
var user = testUtils.DataGenerator.forModel.users[0],
csrfToken = '';
before(function (done) {
var app = express();
ghost({app: app}).then(function (_httpServer) {
httpServer = _httpServer;
// request = supertest(app);
request = supertest.agent(app);
testUtils.clearData()
.then(function () {
return testUtils.initData();
})
.then(function () {
return testUtils.insertDefaultFixtures();
})
.then(function () {
request.get('/ghost/signin/')
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
var pattern_meta = /<meta.*?name="csrf-param".*?content="(.*?)".*?>/i;
pattern_meta.should.exist;
csrfToken = res.text.match(pattern_meta)[1];
setTimeout(function () {
request.post('/ghost/signin/')
.set('X-CSRF-Token', csrfToken)
.send({email: user.email, password: user.password})
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
request.saveCookies(res);
request.get('/ghost/')
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
// console.log('/ghost/', err, res);
csrfToken = res.text.match(pattern_meta)[1];
done();
});
});
}, 2000);
});
}, done);
}).otherwise(function (e) {
console.log('Ghost Error: ', e);
console.log(e.stack);
});
});
after(function () {
httpServer.close();
});
// TODO: currently includes values of type=core
it('can retrieve all settings', function (done) {
request.get(testUtils.API.getApiQuery('settings/'))
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
should.not.exist(res.headers['x-cache-invalidate']);
res.should.be.json;
var jsonResponse = res.body;
jsonResponse.should.exist;
testUtils.API.checkResponse(jsonResponse, 'settings');
done();
});
});
it('can retrieve a setting', function (done) {
request.get(testUtils.API.getApiQuery('settings/title/'))
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
should.not.exist(res.headers['x-cache-invalidate']);
res.should.be.json;
var jsonResponse = res.body;
jsonResponse.should.exist;
testUtils.API.checkResponseValue(jsonResponse, ['key', 'value']);
jsonResponse.key.should.eql('title');
done();
});
});
it('can\'t retrieve non existent setting', function (done) {
request.get(testUtils.API.getApiQuery('settings/testsetting/'))
.expect(404)
.end(function (err, res) {
if (err) {
return done(err);
}
should.not.exist(res.headers['x-cache-invalidate']);
res.should.be.json;
var jsonResponse = res.body;
jsonResponse.should.exist;
testUtils.API.checkResponseValue(jsonResponse, ['error']);
done();
});
});
it('can edit settings', function (done) {
request.get(testUtils.API.getApiQuery('settings/'))
.end(function (err, res) {
if (err) {
return done(err);
}
var jsonResponse = res.body,
changedValue = 'Ghost changed';
jsonResponse.should.exist;
jsonResponse.title = changedValue;
request.put(testUtils.API.getApiQuery('settings/'))
.set('X-CSRF-Token', csrfToken)
.send(jsonResponse)
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
var putBody = res.body;
res.headers['x-cache-invalidate'].should.eql('/*');
res.should.be.json;
putBody.should.exist;
putBody.title.should.eql(changedValue);
testUtils.API.checkResponse(putBody, 'settings');
done();
});
});
});
it('can\'t edit settings with invalid CSRF token', function (done) {
request.get(testUtils.API.getApiQuery('settings'))
.end(function (err, res) {
if (err) {
return done(err);
}
var jsonResponse = res.body,
changedValue = 'Ghost changed';
jsonResponse.should.exist;
jsonResponse.title = changedValue;
request.put(testUtils.API.getApiQuery('settings/'))
.set('X-CSRF-Token', 'invalid-token')
.send(jsonResponse)
.expect(403)
.end(function (err, res) {
if (err) {
return done(err);
}
done();
});
});
});
it('can\'t edit non existent setting', function (done) {
request.get(testUtils.API.getApiQuery('settings'))
.end(function (err, res) {
if (err) {
return done(err);
}
var jsonResponse = res.body,
newValue = 'new value';
jsonResponse.should.exist;
jsonResponse.testvalue = newValue;
request.put(testUtils.API.getApiQuery('settings/'))
.set('X-CSRF-Token', csrfToken)
.send(jsonResponse)
.expect(404)
.end(function (err, res) {
if (err) {
return done(err);
}
var putBody = res.body;
should.not.exist(res.headers['x-cache-invalidate']);
res.should.be.json;
testUtils.API.checkResponseValue(putBody, ['error']);
done();
});
});
});
});

View File

@ -0,0 +1,103 @@
/*global describe, it, before, after */
var supertest = require('supertest'),
express = require('express'),
should = require('should'),
_ = require('lodash'),
testUtils = require('../../../utils'),
ghost = require('../../../../../core'),
httpServer,
request,
agent;
describe('Tag API', function () {
var user = testUtils.DataGenerator.forModel.users[0],
csrfToken = '';
before(function (done) {
var app = express();
ghost({app: app}).then(function (_httpServer) {
httpServer = _httpServer;
// request = supertest(app);
request = supertest.agent(app);
testUtils.clearData()
.then(function () {
return testUtils.initData();
})
.then(function () {
return testUtils.insertDefaultFixtures();
})
.then(function () {
request.get('/ghost/signin/')
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
var pattern_meta = /<meta.*?name="csrf-param".*?content="(.*?)".*?>/i;
pattern_meta.should.exist;
csrfToken = res.text.match(pattern_meta)[1];
setTimeout(function () {
request.post('/ghost/signin/')
.set('X-CSRF-Token', csrfToken)
.send({email: user.email, password: user.password})
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
request.saveCookies(res);
request.get('/ghost/')
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
csrfToken = res.text.match(pattern_meta)[1];
done();
});
});
}, 2000);
});
}, done);
}).otherwise(function (e) {
console.log('Ghost Error: ', e);
console.log(e.stack);
});
});
after(function () {
httpServer.close();
});
it('can retrieve all tags', function (done) {
request.get(testUtils.API.getApiQuery('tags/'))
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
should.not.exist(res.headers['x-cache-invalidate']);
res.should.be.json;
var jsonResponse = res.body;
jsonResponse.should.exist;
jsonResponse.should.have.length(6);
testUtils.API.checkResponse(jsonResponse[0], 'tag');
done();
});
});
});

View File

@ -0,0 +1,199 @@
/*global describe, it, before, after */
var supertest = require('supertest'),
express = require('express'),
should = require('should'),
_ = require('lodash'),
testUtils = require('../../../utils'),
ghost = require('../../../../../core'),
httpServer,
request,
agent;
describe('User API', function () {
var user = testUtils.DataGenerator.forModel.users[0],
csrfToken = '';
before(function (done) {
var app = express();
ghost({app: app}).then(function (_httpServer) {
httpServer = _httpServer;
// request = supertest(app);
request = supertest.agent(app);
testUtils.clearData()
.then(function () {
return testUtils.initData();
})
.then(function () {
return testUtils.insertDefaultFixtures();
})
.then(function () {
request.get('/ghost/signin/')
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
var pattern_meta = /<meta.*?name="csrf-param".*?content="(.*?)".*?>/i;
pattern_meta.should.exist;
csrfToken = res.text.match(pattern_meta)[1];
setTimeout(function () {
request.post('/ghost/signin/')
.set('X-CSRF-Token', csrfToken)
.send({email: user.email, password: user.password})
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
request.saveCookies(res);
request.get('/ghost/')
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
// console.log('/ghost/', err, res);
csrfToken = res.text.match(pattern_meta)[1];
done();
});
});
}, 2000);
});
}, done);
}).otherwise(function (e) {
console.log('Ghost Error: ', e);
console.log(e.stack);
});
});
after(function () {
httpServer.close();
});
it('can retrieve all users', function (done) {
request.get(testUtils.API.getApiQuery('users/'))
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
should.not.exist(res.headers['x-cache-invalidate']);
res.should.be.json;
var jsonResponse = res.body;
jsonResponse[0].should.exist;
testUtils.API.checkResponse(jsonResponse[0], 'user');
done();
});
});
it('can retrieve a user', function (done) {
request.get(testUtils.API.getApiQuery('users/me/'))
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
should.not.exist(res.headers['x-cache-invalidate']);
res.should.be.json;
var jsonResponse = res.body;
jsonResponse.should.exist;
testUtils.API.checkResponse(jsonResponse, 'user');
done();
});
});
it('can\'t retrieve non existent user', function (done) {
request.get(testUtils.API.getApiQuery('users/99/'))
.expect(404)
.end(function (err, res) {
if (err) {
return done(err);
}
should.not.exist(res.headers['x-cache-invalidate']);
res.should.be.json;
var jsonResponse = res.body;
jsonResponse.should.exist;
testUtils.API.checkResponseValue(jsonResponse, ['error']);
done();
});
});
it('can edit a user', function (done) {
request.get(testUtils.API.getApiQuery('users/me/'))
.end(function (err, res) {
if (err) {
return done(err);
}
var jsonResponse = res.body,
changedValue = 'joe-bloggs.ghost.org';
jsonResponse.should.exist;
jsonResponse.website = changedValue;
request.put(testUtils.API.getApiQuery('users/me/'))
.set('X-CSRF-Token', csrfToken)
.send(jsonResponse)
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
var putBody = res.body;
res.headers['x-cache-invalidate'].should.eql('/*');
res.should.be.json;
putBody.should.exist;
putBody.website.should.eql(changedValue);
testUtils.API.checkResponse(putBody, 'user');
done();
});
});
});
it('can\'t edit a user with invalid CSRF token', function (done) {
request.get(testUtils.API.getApiQuery('users/me/'))
.end(function (err, res) {
if (err) {
return done(err);
}
var jsonResponse = res.body,
changedValue = 'joe-bloggs.ghost.org';
jsonResponse.should.exist;
jsonResponse.website = changedValue;
request.put(testUtils.API.getApiQuery('users/me/'))
.set('X-CSRF-Token', 'invalid-token')
.send(jsonResponse)
.expect(403)
.end(function (err, res) {
if (err) {
return done(err);
}
done();
});
});
});
});

View File

@ -6,11 +6,13 @@
// But then again testing real code, rather than mock code, might be more useful...
var request = require('supertest'),
express = require('express'),
should = require('should'),
moment = require('moment'),
testUtils = require('../../utils'),
config = require('../../../server/config'),
ghost = require('../../../../core'),
httpServer,
ONE_HOUR_S = 60 * 60,
ONE_YEAR_S = 365 * 24 * ONE_HOUR_S,
@ -38,15 +40,26 @@ describe('Frontend Routing', function () {
}
before(function (done) {
testUtils.clearData().then(function () {
// we initialise data, but not a user. No user should be required for navigating the frontend
return testUtils.initData();
}).then(function () {
done();
}, done);
var app = express();
// Setup the request object with the correct URL
request = request(config().url);
ghost({app: app}).then(function (_httpServer) {
// Setup the request object with the ghost express app
httpServer = _httpServer;
request = request(app);
testUtils.clearData().then(function () {
// we initialise data, but not a user. No user should be required for navigating the frontend
return testUtils.initData();
}).then(function () {
done();
}, done);
}).otherwise(function (e) {
console.log('Ghost Error: ', e);
console.log(e.stack);
});
});
after(function () {
httpServer.close();
});
describe('Home', function () {
@ -129,6 +142,14 @@ describe('Frontend Routing', function () {
.expect(302)
.end(doEnd(done));
});
it('should get redirected to /rss/ from /feed/', function (done) {
request.get('/feed/')
.expect('Location', '/rss/')
.expect('Cache-Control', cacheRules.year)
.expect(301)
.end(doEnd(done));
});
});
// ### The rest of the tests require more data

View File

@ -0,0 +1,53 @@
/*globals describe, before, beforeEach, afterEach, it */
var testUtils = require('../../utils'),
should = require('should'),
// Stuff we are testing
DataGenerator = require('../../utils/fixtures/data-generator'),
dbAPI = require('../../../server/api/db');
TagsAPI = require('../../../server/api/tags');
PostAPI = require('../../../server/api/posts');
describe('DB API', function () {
before(function (done) {
testUtils.clearData().then(function () {
done();
}, done);
});
beforeEach(function (done) {
testUtils.initData()
.then(function () {
return testUtils.insertDefaultFixtures();
})
.then(function () {
done();
}, done);
});
afterEach(function (done) {
testUtils.clearData().then(function () {
done();
}, done);
});
it('delete all content', function (done) {
dbAPI.deleteAllContent().then(function (result){
should.exist(result.message);
result.message.should.equal('Successfully deleted all content from your blog.')
}).then(function () {
TagsAPI.browse().then(function (results) {
should.exist(results);
results.length.should.equal(0);
});
}).then(function () {
PostAPI.browse().then(function (results) {
should.exist(results);
results.posts.length.should.equal(0);
done();
});
}).then(null, done);
});
});

View File

@ -419,7 +419,7 @@ describe('Post Model', function () {
}).then(function (paginationResult) {
paginationResult.page.should.equal(2);
paginationResult.limit.should.equal(15);
paginationResult.posts.length.should.equal(9);
paginationResult.posts.length.should.equal(11);
paginationResult.pages.should.equal(2);
paginationResult.aspect.tag.name.should.equal('injection');
paginationResult.aspect.tag.slug.should.equal('injection');

View File

@ -65,7 +65,7 @@ describe('Settings Model', function () {
should.exist(found);
found.attributes.value.should.equal(firstSetting.attributes.value);
found.get('value').should.equal(firstSetting.attributes.value);
done();
@ -206,7 +206,9 @@ describe('Settings Model', function () {
return SettingsModel.findAll();
}).then(function (allSettings) {
allSettings.length.should.be.above(0);
return SettingsModel.read('description');
return SettingsModel.read('description').then(function (descriptionSetting) {
return descriptionSetting;
});
}).then(function (descriptionSetting) {
// Testing against the actual value in default-settings.json feels icky,
// but it's easier to fix the test if that ever changes than to mock out that behaviour

View File

@ -177,9 +177,13 @@ describe('Tag Model', function () {
return PostModel.read({id: postModel.id, status: 'all'}, { withRelated: ['tags']});
}).then(function (reloadedPost) {
var tagModels = reloadedPost.related('tags').models,
tagNames = tagModels.map(function (t) { return t.attributes.name; });
tagNames = tagModels.map(function (t) { return t.attributes.name; }),
tagIds = _.pluck(tagModels, 'id');
tagNames.sort().should.eql(['tag1', 'tag2', 'tag3']);
tagModels[2].id.should.eql(4); // make sure it hasn't just added a new tag with the same name
// make sure it hasn't just added a new tag with the same name
// Don't expect a certain order in results - check for number of items!
Math.max.apply(Math, tagIds).should.eql(4);
done();
}).then(null, done);
@ -248,9 +252,14 @@ describe('Tag Model', function () {
return PostModel.read({id: postModel.id, status: 'all'}, { withRelated: ['tags']});
}).then(function (reloadedPost) {
var tagModels = reloadedPost.related('tags').models,
tagNames = tagModels.map(function (t) { return t.attributes.name; });
tagNames = tagModels.map(function (t) { return t.attributes.name; }),
tagIds = _.pluck(tagModels, 'id');
tagNames.sort().should.eql(['tag1', 'tag2', 'tag3']);
tagModels[2].id.should.eql(4); // make sure it hasn't just added a new tag with the same name
// make sure it hasn't just added a new tag with the same name
// Don't expect a certain order in results - check for number of items!
Math.max.apply(Math, tagIds).should.eql(4);
done();
}).then(null, done);
@ -279,9 +288,14 @@ describe('Tag Model', function () {
return PostModel.read({id: postModel.id, status: 'all'}, { withRelated: ['tags']});
}).then(function (reloadedPost) {
var tagModels = reloadedPost.related('tags').models,
tagNames = tagModels.map(function (t) { return t.attributes.name; });
tagNames = tagModels.map(function (t) { return t.get('name'); }),
tagIds = _.pluck(tagModels, 'id');
tagNames.sort().should.eql(['tag1', 'tag2', 'tag3', 'tag4']);
tagModels[2].id.should.eql(4); // make sure it hasn't just added a new tag with the same name
// make sure it hasn't just added a new tag with the same name
// Don't expect a certain order in results - check for number of items!
Math.max.apply(Math, tagIds).should.eql(5);
done();
}).then(null, done);

View File

@ -141,6 +141,15 @@ describe('User Model', function run() {
}, done);
});
it('sets last login time on successful login', function (done) {
var userData = testUtils.DataGenerator.forModel.users[0];
UserModel.check({email: userData.email, pw:userData.password}).then(function (activeUser) {
should.exist(activeUser.get('last_login'));
done();
}).then(null, done);
});
it('can\'t add second', function (done) {
var userData = testUtils.DataGenerator.forModel.users[1];

View File

@ -3,16 +3,16 @@ var testUtils = require('../utils'),
should = require('should'),
when = require('when'),
sinon = require('sinon'),
express = require("express"),
express = require('express'),
rewire = require('rewire')
// Stuff we are testing
colors = require("colors"),
errors = require('../../server/errorHandling'),
colors = require('colors'),
errors = rewire('../../server/errorHandling'),
// storing current environment
currentEnv = process.env.NODE_ENV,
ONE_HOUR_S = 60 * 60;
currentEnv = process.env.NODE_ENV;
describe("Error handling", function () {
describe('Error handling', function () {
// Just getting rid of jslint unused error
should.exist(errors);
@ -49,7 +49,7 @@ describe("Error handling", function () {
var logStub;
beforeEach(function () {
logStub = sinon.stub(console, "error");
logStub = sinon.stub(console, 'error');
// give environment a value that will console log
process.env.NODE_ENV = "development";
});
@ -60,142 +60,115 @@ describe("Error handling", function () {
process.env.NODE_ENV = currentEnv;
});
it("logs errors from error objects", function () {
var err = new Error("test1");
it('logs errors from error objects', function () {
var err = new Error('test1');
errors.logError(err);
// Calls log with message on Error objects
logStub.calledThrice.should.be.true;
logStub.firstCall.calledWith("\nERROR:".red, err.message.red).should.be.true;
logStub.secondCall.calledWith('').should.be.true;
logStub.thirdCall.calledWith(err.stack, '\n').should.be.true;
logStub.calledOnce.should.be.true;
logStub.calledWith('\nERROR:'.red, err.message.red, '\n', '\n', err.stack, '\n').should.be.true;
});
it("logs errors from strings", function () {
var err = "test2";
it('logs errors from strings', function () {
var err = 'test2';
errors.logError(err);
// Calls log with string on strings
logStub.calledTwice.should.be.true;
logStub.firstCall.calledWith("\nERROR:".red, err.red).should.be.true;
logStub.secondCall.calledWith('').should.be.true;
logStub.calledOnce.should.be.true;
logStub.calledWith('\nERROR:'.red, err.red, '\n').should.be.true;
});
it("logs errors from an error object and two string arguments", function () {
var err = new Error("test1"),
message = "Testing";
it('logs errors from an error object and two string arguments', function () {
var err = new Error('test1'),
message = 'Testing';
errors.logError(err, message, message);
// Calls log with message on Error objects
logStub.callCount.should.equal(5);
logStub.calledWith("\nERROR:".red, err.message.red).should.be.true;
logStub.firstCall.calledWith("\nERROR:".red, err.message.red).should.be.true;
logStub.secondCall.calledWith(message.white).should.be.true;
logStub.thirdCall.calledWith(message.green).should.be.true;
logStub.getCall(3).calledWith('').should.be.true; //nth call uses zero-based numbering
logStub.lastCall.calledWith(err.stack, '\n').should.be.true;
logStub.calledOnce.should.be.true;
logStub.calledWith('\nERROR:'.red, err.message.red, '\n', message.white, '\n', message.green, '\n', err.stack, '\n');
});
it("logs errors from three string arguments", function () {
var message = "Testing";
it('logs errors from three string arguments', function () {
var message = 'Testing';
errors.logError(message, message, message);
// Calls log with message on Error objects
logStub.callCount.should.equal(4);
logStub.firstCall.calledWith("\nERROR:".red, message.red).should.be.true;
logStub.secondCall.calledWith(message.white).should.be.true;
logStub.thirdCall.calledWith(message.green).should.be.true;
logStub.lastCall.calledWith('').should.be.true;
logStub.calledOnce.should.be.true;
logStub.calledWith('\nERROR:'.red, message.red, '\n', message.white, '\n', message.green, '\n').should.be.true;
});
it("logs errors from an undefined error argument", function () {
var message = "Testing";
it('logs errors from an undefined error argument', function () {
var message = 'Testing';
errors.logError(undefined, message, message);
// Calls log with message on Error objects
logStub.callCount.should.equal(4);
logStub.firstCall.calledWith("\nERROR:".red, "An unknown error occurred.".red).should.be.true;
logStub.secondCall.calledWith(message.white).should.be.true;
logStub.thirdCall.calledWith(message.green).should.be.true;
logStub.lastCall.calledWith('').should.be.true;
logStub.calledOnce.should.be.true;
logStub.calledWith('\nERROR:'.red, 'An unknown error occurred.'.red, '\n', message.white, '\n', message.green , '\n').should.be.true;
});
it("logs errors from an undefined context argument", function () {
var message = "Testing";
it('logs errors from an undefined context argument', function () {
var message = 'Testing';
errors.logError(message, undefined, message);
// Calls log with message on Error objects
logStub.callCount.should.equal(3);
logStub.firstCall.calledWith("\nERROR:".red, message.red).should.be.true;
logStub.secondCall.calledWith(message.green).should.be.true;
logStub.lastCall.calledWith('').should.be.true;
logStub.calledOnce.should.be.true;
logStub.calledWith('\nERROR:'.red, message.red, '\n', message.green, '\n').should.be.true;
});
it("logs errors from an undefined help argument", function () {
var message = "Testing";
it('logs errors from an undefined help argument', function () {
var message = 'Testing';
errors.logError(message, message, undefined);
// Calls log with message on Error objects
logStub.callCount.should.equal(3);
logStub.firstCall.calledWith("\nERROR:".red, message.red).should.be.true;
logStub.secondCall.calledWith(message.white).should.be.true;
logStub.lastCall.calledWith('').should.be.true;
logStub.calledOnce.should.be.true;
logStub.calledWith('\nERROR:'.red, message.red, '\n', message.white, '\n').should.be.true;
});
it("logs errors from a null error argument", function () {
var message = "Testing";
it('logs errors from a null error argument', function () {
var message = 'Testing';
errors.logError(null, message, message);
// Calls log with message on Error objects
logStub.callCount.should.equal(4);
logStub.firstCall.calledWith("\nERROR:".red, "An unknown error occurred.".red).should.be.true;
logStub.secondCall.calledWith(message.white).should.be.true;
logStub.thirdCall.calledWith(message.green).should.be.true;
logStub.lastCall.calledWith('').should.be.true;
logStub.calledOnce.should.be.true;
logStub.calledWith('\nERROR:'.red, 'An unknown error occurred.'.red, '\n', message.white, '\n', message.green, '\n').should.be.true;
});
it("logs errors from a null context argument", function () {
var message = "Testing";
it('logs errors from a null context argument', function () {
var message = 'Testing';
errors.logError(message, null, message);
// Calls log with message on Error objects
logStub.callCount.should.equal(3);
logStub.firstCall.calledWith("\nERROR:".red, message.red).should.be.true;
logStub.secondCall.calledWith(message.green).should.be.true;
logStub.lastCall.calledWith('').should.be.true;
logStub.calledOnce.should.be.true;
logStub.firstCall.calledWith('\nERROR:'.red, message.red, '\n', message.green, '\n').should.be.true;
});
it("logs errors from a null help argument", function () {
var message = "Testing";
it('logs errors from a null help argument', function () {
var message = 'Testing';
errors.logError(message, message, null);
// Calls log with message on Error objects
logStub.callCount.should.equal(3);
logStub.firstCall.calledWith("\nERROR:".red, message.red).should.be.true;
logStub.secondCall.calledWith(message.white).should.be.true;
logStub.lastCall.calledWith('').should.be.true;
logStub.calledOnce.should.be.true;
logStub.firstCall.calledWith('\nERROR:'.red, message.red, '\n', message.white, '\n').should.be.true;
});
it("logs promise errors and redirects", function (done) {
it('logs promise errors and redirects', function (done) {
var def = when.defer(),
prom = def.promise,
req = null,
@ -204,15 +177,15 @@ describe("Error handling", function () {
return;
}
},
redirectStub = sinon.stub(res, "redirect");
redirectStub = sinon.stub(res, 'redirect');
// give environment a value that will console log
prom.then(function () {
throw new Error("Ran success handler");
}, errors.logErrorWithRedirect("test1", null, null, "/testurl", req, res));
throw new Error('Ran success handler');
}, errors.logErrorWithRedirect('test1', null, null, '/testurl', req, res));
prom.otherwise(function () {
logStub.calledWith("\nERROR:".red, "test1".red).should.equal(true);
logStub.calledWith('\nERROR:'.red, 'test1'.red).should.equal(true);
logStub.restore();
redirectStub.calledWith('/testurl').should.equal(true);
@ -225,10 +198,31 @@ describe("Error handling", function () {
});
describe('Rendering', function () {
var sandbox;
var sandbox,
originalConfig;
before(function () {
errors.updateActiveTheme('casper', false);
originalConfig = errors.__get__('config');
errors.__set__('config', function () {
return {
'paths': {
'themePath': '/content/themes',
'availableThemes': {
'casper': {
'assets': null,
'default.hbs': '/content/themes/casper/default.hbs',
'index.hbs': '/content/themes/casper/index.hbs',
'page.hbs': '/content/themes/casper/page.hbs',
'tag.hbs': '/content/themes/casper/tag.hbs'
},
'theme-with-error': {
'error.hbs':''
}
}
}
}
});
errors.updateActiveTheme('casper');
});
beforeEach(function () {
@ -239,6 +233,9 @@ describe("Error handling", function () {
sandbox.restore();
});
after(function () {
errors.__set__('config', originalConfig);
});
it('Renders end-of-middleware 404 errors correctly', function (done) {
var req = {method: 'GET'},
@ -349,5 +346,26 @@ describe("Error handling", function () {
err.code = 500;
errors.error500(err, req, res, null);
});
it('Renders custom error template if one exists', function(done){
var code = 404,
error = {message:'Custom view test'},
req = {
session: null
},
res = {
status: function(code) {
return this;
},
render: function(view, model, fn){
view.should.eql('error');
errors.updateActiveTheme('casper');
done();
}
},
next = null;
errors.updateActiveTheme('theme-with-error');
errors.renderErrorPage(code, error, req, res, next);
})
});
});

View File

@ -296,18 +296,24 @@ describe('Core Helpers', function () {
it('can render class string for context', function (done) {
when.all([
helpers.body_class.call({relativeUrl: '/'}),
helpers.body_class.call({relativeUrl: '/a-post-title'}),
helpers.body_class.call({relativeUrl: '/page/4'})
helpers.body_class.call({relativeUrl: '/a-post-title', post: {}}),
helpers.body_class.call({relativeUrl: '/page/4'}),
helpers.body_class.call({relativeUrl: '/tag/foo', tag: { slug: 'foo'}}),
helpers.body_class.call({relativeUrl: '/tag/foo/page/2', tag: { slug: 'foo'}})
]).then(function (rendered) {
rendered.length.should.equal(3);
rendered.length.should.equal(5);
should.exist(rendered[0]);
should.exist(rendered[1]);
should.exist(rendered[2]);
should.exist(rendered[3]);
should.exist(rendered[4]);
rendered[0].string.should.equal('home-template');
rendered[1].string.should.equal('post-template');
rendered[2].string.should.equal('archive-template');
rendered[3].string.should.equal('tag-template tag-foo');
rendered[4].string.should.equal('archive-template tag-template tag-foo');
done();
}).then(null, done);
@ -446,8 +452,7 @@ describe('Core Helpers', function () {
should.exist(handlebars.helpers.ghost_foot);
});
it('returns meta tag string', function (done) {
it('outputs correct jquery for development mode', function (done) {
helpers.assetHash = 'abc';
helpers.ghost_foot.call().then(function (rendered) {
@ -457,6 +462,18 @@ describe('Core Helpers', function () {
done();
}).then(null, done);
});
it('outputs correct jquery for production mode', function (done) {
helpers.assetHash = 'abc';
helpers.__set__('isProduction', true);
helpers.ghost_foot.call().then(function (rendered) {
should.exist(rendered);
rendered.string.should.match(/<script src=".*\/public\/jquery.min.js\?v=abc"><\/script>/);
done();
}).then(null, done);
});
});
describe('has Block Helper', function () {

View File

@ -5,14 +5,15 @@
*/
/*globals describe, it */
var testUtils = require('../utils'),
should = require('should'),
var testUtils = require('../utils'),
should = require('should'),
// Stuff we are testing
Showdown = require('showdown'),
github = require('../../shared/lib/showdown/extensions/github'),
ghostdown = require('../../clientold/assets/lib/showdown/extensions/ghostdown'),
converter = new Showdown.converter({extensions: [ghostdown, github]});
Showdown = require('showdown'),
ghostgfm = require('../../shared/lib/showdown/extensions/ghostgfm'),
ghostimagepreview = require('../../shared/lib/showdown/extensions/ghostimagepreview'),
converter = new Showdown.converter({extensions: [ghostimagepreview, ghostgfm]});
describe("Showdown client side converter", function () {
/*jslint regexp: true */
@ -107,7 +108,7 @@ describe("Showdown client side converter", function () {
it("should turn newlines into br tags in simple cases", function () {
var testPhrases = [
{input: "fizz\nbuzz", output: /^<p>fizz <br \/>\nbuzz<\/p>$/},
{input: "Hello world\nIt's a fine day", output: /^<p>Hello world <br \/>\nIt\'s a fine day<\/p>$/},
{input: "Hello world\nIt is a fine day", output: /^<p>Hello world <br \/>\nIt is a fine day<\/p>$/},
{input: "\"first\nsecond", output: /^<p>\"first <br \/>\nsecond<\/p>$/},
{input: "\'first\nsecond", output: /^<p>\'first <br \/>\nsecond<\/p>$/}
],
@ -122,7 +123,7 @@ describe("Showdown client side converter", function () {
it("should convert newlines in all groups", function () {
var testPhrases = [
{input: "ruby\npython\nerlang", output: /^<p>ruby <br \/>\npython <br \/>\nerlang<\/p>$/},
{input: "Hello world\nIt's a fine day\nout", output: /^<p>Hello world <br \/>\nIt\'s a fine day <br \/>\nout<\/p>$/}
{input: "Hello world\nIt is a fine day\nout", output: /^<p>Hello world <br \/>\nIt is a fine day <br \/>\nout<\/p>$/}
],
processedMarkup;
@ -134,10 +135,13 @@ describe("Showdown client side converter", function () {
it("should convert newlines in even long groups", function () {
var testPhrases = [
{input: "ruby\npython\nerlang\ngo", output: /^<p>ruby <br \/>\npython <br \/>\nerlang <br \/>\ngo<\/p>$/},
{
input: "Hello world\nIt's a fine day\noutside\nthe window",
output: /^<p>Hello world <br \/>\nIt\'s a fine day <br \/>\noutside <br \/>\nthe window<\/p>$/
input: "ruby\npython\nerlang\ngo",
output: /^<p>ruby <br \/>\npython <br \/>\nerlang <br \/>\ngo<\/p>$/
},
{
input: "Hello world\nIt is a fine day\noutside\nthe window",
output: /^<p>Hello world <br \/>\nIt is a fine day <br \/>\noutside <br \/>\nthe window<\/p>$/
}
],
processedMarkup;
@ -150,8 +154,14 @@ describe("Showdown client side converter", function () {
it("should not convert newlines in lists", function () {
var testPhrases = [
{input: "#fizz\n# buzz\n### baz", output: /^<h1 id="fizz">fizz<\/h1>\n\n<h1 id="buzz">buzz<\/h1>\n\n<h3 id="baz">baz<\/h3>$/},
{input: "* foo\n* bar", output: /^<ul>\n<li>foo<\/li>\n<li>bar<\/li>\n<\/ul>$/}
{
input: "#fizz\n# buzz\n### baz",
output: /^<h1 id="fizz">fizz<\/h1>\n\n<h1 id="buzz">buzz<\/h1>\n\n<h3 id="baz">baz<\/h3>$/
},
{
input: "* foo\n* bar",
output: /^<ul>\n<li>foo<\/li>\n<li>bar<\/li>\n<\/ul>$/
}
],
processedMarkup;
@ -212,7 +222,7 @@ describe("Showdown client side converter", function () {
var testPhrases = [
{
input: "[Google][1]\n\n[1]: http://google.co.uk",
output: /^<p><a href="http:\/\/google.co.uk">Google<\/a><\/p>$/,
output: /^<p><a href="http:\/\/google.co.uk">Google<\/a><\/p>$/
},
{
input: "[Google][1]\n\n[1]: http://google.co.uk \"some text\"",
@ -456,4 +466,41 @@ describe("Showdown client side converter", function () {
processedMarkup.should.match(testPhrase.output);
});
});
it("should output block HTML untouched", function () {
var testPhrases = [
{
input: "<table class=\"test\">\n <tr>\n <td>Foo</td>\n </tr>\n <tr>\n <td>Bar</td>\n </tr>\n</table>",
output: /^<table class=\"test\"> \n <tr>\n <td>Foo<\/td>\n <\/tr>\n <tr>\n <td>Bar<\/td>\n <\/tr>\n<\/table>$/
},
{
input: "<hr />",
output: /^<hr \/>$/
},
{ // audio isn't counted as a block tag by showdown so gets wrapped in <p></p>
input: "<audio class=\"podcastplayer\" controls>\n <source src=\"foobar.mp3\" type=\"audio/mp3\" preload=\"none\"></source>\n <source src=\"foobar.off\" type=\"audio/ogg\" preload=\"none\"></source>\n</audio>",
output: /^<audio class=\"podcastplayer\" controls> \n <source src=\"foobar.mp3\" type=\"audio\/mp3\" preload=\"none\"><\/source>\n <source src=\"foobar.off\" type=\"audio\/ogg\" preload=\"none\"><\/source>\n<\/audio>$/,
}
];
testPhrases.forEach(function (testPhrase) {
processedMarkup = converter.makeHtml(testPhrase.input);
processedMarkup.should.match(testPhrase.output);
});
});
// Waiting for showdown typography to be updated
// it("should correctly convert quotes to curly quotes", function () {
// var testPhrases = [
// {
// input: "Hello world\nIt's a fine day\nout",
// output: /^<p>Hello world <br \/>\nIts a fine day <br \/>\nout<\/p>$/}
// ];
//
// testPhrases.forEach(function (testPhrase) {
// processedMarkup = converter.makeHtml(testPhrase.input);
// processedMarkup.should.match(testPhrase.output);
// });
// })
});

View File

@ -4,12 +4,11 @@
*/
/*globals describe, it */
var testUtils = require('../utils'),
should = require('should'),
var testUtils = require('../utils'),
should = require('should'),
// Stuff we are testing
ghPath = "../../shared/lib/showdown/extensions/github.js",
github = require(ghPath);
ghostgfm = require('../../shared/lib/showdown/extensions/ghostgfm');
function _ExecuteExtension(ext, text) {
if (ext.regex) {
@ -21,20 +20,20 @@ function _ExecuteExtension(ext, text) {
}
function _ConvertPhrase(testPhrase) {
return github().reduce(function (text, ext) {
return ghostgfm().reduce(function (text, ext) {
return _ExecuteExtension(ext, text);
}, testPhrase);
}
describe("Github showdown extensions", function () {
describe("Ghost GFM showdown extension", function () {
/*jslint regexp: true */
it("should export an array of methods for processing", function () {
github.should.be.a.function;
github().should.be.an.Array;
ghostgfm.should.be.a.function;
ghostgfm().should.be.an.Array;
github().forEach(function (processor) {
ghostgfm().forEach(function (processor) {
processor.should.be.an.Object;
processor.should.have.property("type");
processor.type.should.be.a.String;
@ -50,6 +49,14 @@ describe("Github showdown extensions", function () {
processedMarkup.should.match(testPhrase.output);
});
it("should allow 4 underscores", function () {
var testPhrase = {input: 'Ghost ____', output: /Ghost\s(?:&#95;){4}$/},
processedMarkup = _ConvertPhrase(testPhrase.input);
processedMarkup.should.match(testPhrase.output);
});
it("should auto-link URL in text with markdown syntax", function () {
var testPhrases = [
{

View File

@ -6,21 +6,20 @@
*/
/*globals describe, it */
var testUtils = require('../utils'),
should = require('should'),
var testUtils = require('../utils'),
should = require('should'),
// Stuff we are testing
gdPath = "../../clientold/assets/lib/showdown/extensions/ghostdown.js",
ghostdown = require(gdPath);
ghostimagepreview = require('../../shared/lib/showdown/extensions/ghostimagepreview');
describe("Ghostdown showdown extensions", function () {
describe("Ghost Image Preview showdown extension", function () {
it("should export an array of methods for processing", function () {
ghostdown.should.be.a.function;
ghostdown().should.be.an.instanceof(Array);
ghostimagepreview.should.be.a.function;
ghostimagepreview().should.be.an.instanceof(Array);
ghostdown().forEach(function (processor) {
ghostimagepreview().forEach(function (processor) {
processor.should.be.an.Object;
processor.should.have.property("type");
processor.should.have.property("filter");
@ -46,7 +45,7 @@ describe("Ghostdown showdown extensions", function () {
]
.forEach(function (imageMarkup) {
var processedMarkup =
ghostdown().reduce(function (prev, processor) {
ghostimagepreview().reduce(function (prev, processor) {
return processor.filter(prev);
}, imageMarkup);
@ -55,15 +54,6 @@ describe("Ghostdown showdown extensions", function () {
});
});
it("should allow 4 underscores", function () {
var processedMarkup =
ghostdown().reduce(function (prev, processor) {
return processor.filter(prev);
}, "Ghost ____");
processedMarkup.should.match(/Ghost\s(?:&#95;){4}$/);
});
it("should correctly include an image", function () {
[
"![image and another,/ image](http://dsurl.stuff)",
@ -75,7 +65,7 @@ describe("Ghostdown showdown extensions", function () {
]
.forEach(function (imageMarkup) {
var processedMarkup =
ghostdown().reduce(function (prev, processor) {
ghostimagepreview().reduce(function (prev, processor) {
return processor.filter(prev);
}, imageMarkup);

View File

@ -2,6 +2,7 @@
var fs = require('fs-extra'),
path = require('path'),
should = require('should'),
config = require('../../server/config'),
sinon = require('sinon'),
when = require('when'),
localfilesystem = require('../../server/storage/localfilesystem');
@ -44,7 +45,7 @@ describe('Local File System Storage', function () {
it('should send correct path to image when original file has spaces', function (done) {
image.name = 'AN IMAGE.jpg';
localfilesystem.save(image).then(function (url) {
url.should.equal('/content/images/2013/Sep/AN_IMAGE.jpg');
url.should.equal('/content/images/2013/Sep/AN-IMAGE.jpg');
return done();
}).then(null, done);
});
@ -122,6 +123,27 @@ describe('Local File System Storage', function () {
}).then(null, done);
});
describe('when a custom content path is used', function () {
var origContentPath = config().paths.contentPath;
var origImagesPath = config().paths.imagesPath;
beforeEach(function () {
config().paths.contentPath = config().paths.appRoot + '/var/ghostcms';
config().paths.imagesPath = config().paths.appRoot + '/var/ghostcms/' + config().paths.imagesRelPath;
});
afterEach(function () {
config().paths.contentPath = origContentPath;
config().paths.imagesPath = origImagesPath;
});
it('should send the correct path to image', function (done) {
localfilesystem.save(image).then(function (url) {
url.should.equal('/content/images/2013/Sep/IMAGE.jpg');
return done();
}).then(null, done);
});
});
describe('on Windows', function () {
var truePathSep = path.sep;
@ -139,7 +161,15 @@ describe('Local File System Storage', function () {
path.sep = '\\';
path.join.returns('content\\images\\2013\\Sep\\IMAGE.jpg');
localfilesystem.save(image).then(function (url) {
url.should.equal('/content/images/2013/Sep/IMAGE.jpg');
if (truePathSep === '\\') {
url.should.equal('/content/images/2013/Sep/IMAGE.jpg');
} else {
// if this unit test is run on an OS that uses forward slash separators,
// localfilesystem.save() will use a path.relative() call on
// one path with backslash separators and one path with forward
// slashes and it returns a path that needs to be normalized
path.normalize(url).should.equal('/content/images/2013/Sep/IMAGE.jpg');
}
return done();
}).then(null, done);
});

View File

@ -0,0 +1,46 @@
/*globals describe, beforeEach, afterEach, it*/
var assert = require('assert'),
http = require('http'),
nock = require('nock'),
settings = require('../../server/api').settings;
should = require('should'),
sinon = require('sinon'),
testUtils = require('../utils'),
when = require('when'),
xmlrpc = require('../../server/xmlrpc'),
// storing current environment
currentEnv = process.env.NODE_ENV;
describe('XMLRPC', function () {
var sandbox;
beforeEach(function () {
sandbox = sinon.sandbox.create();
// give environment a value that will ping
process.env.NODE_ENV = "production";
});
afterEach(function () {
sandbox.restore();
// reset the environment
process.env.NODE_ENV = currentEnv;
});
it('should execute two pings', function (done) {
var ping1 = nock('http://blogsearch.google.com').post('/ping/RPC2').reply(200),
ping2 = nock('http://rpc.pingomatic.com').post('/').reply(200),
testPost = testUtils.DataGenerator.Content.posts[2],
settingsStub = sandbox.stub(settings, 'read', function () {
return when({value: '/:slug/'});
});
xmlrpc.ping(testPost).then(function () {
ping1.isDone().should.be.true;
ping2.isDone().should.be.true;
done();
});
});
});

View File

@ -21,6 +21,9 @@ var _ = require('lodash'),
notification: ['type', 'message', 'status', 'id']
};
function getApiQuery (route) {
return url.resolve(ApiRouteBase, route);
}
function getApiURL (route) {
var baseURL = url.resolve(schema + host + ':' + port, ApiRouteBase);
@ -46,6 +49,7 @@ function checkResponseValue (jsonResponse, properties) {
module.exports = {
getApiURL: getApiURL,
getApiQuery: getApiQuery,
getSigninURL: getSigninURL,
getAdminURL: getAdminURL,
checkResponse: checkResponse,

View File

@ -1,5 +1,6 @@
var knex = require('../../server/models/base').knex,
when = require('when'),
sequence = require('when/sequence'),
nodefn = require('when/node/function'),
_ = require('lodash'),
fs = require('fs-extra'),
@ -29,14 +30,13 @@ function insertPosts() {
function insertMorePosts(max) {
var lang,
status,
posts,
posts = [],
promises = [],
i, j, k = 0;
max = max || 50;
for (i = 0; i < 2; i += 1) {
posts = [];
lang = i % 2 ? 'en' : 'fr';
posts.push(DataGenerator.forKnex.createGenericPost(k++, null, lang));
@ -44,18 +44,21 @@ function insertMorePosts(max) {
status = j % 2 ? 'draft' : 'published';
posts.push(DataGenerator.forKnex.createGenericPost(k++, status, lang));
}
promises.push(knex('posts').insert(posts));
}
return when.all(promises);
return sequence(_.times(posts.length, function(index) {
return function() {
return knex('posts').insert(posts[index]);
}
}));
}
function insertMorePostsTags(max) {
max = max || 50;
return when.all([
knex('posts').select('id'),
// PostgreSQL can return results in any order
knex('posts').orderBy('id', 'asc').select('id'),
knex('tags').select('id', 'name')
]).then(function (results) {
var posts = _.pluck(results[0], 'id'),
@ -74,9 +77,11 @@ function insertMorePostsTags(max) {
promises.push(DataGenerator.forKnex.createPostsTags(posts[i], injectionTagId));
}
promises.push(knex('posts_tags').insert(promises));
return when.all(promises);
return sequence(_.times(promises.length, function(index) {
return function() {
return knex('posts_tags').insert(promises[index]);
};
}));
});
}

View File

@ -1,6 +1,6 @@
{
"name" : "ghost",
"version" : "0.4.1",
"version" : "0.4.2",
"description" : "Just a blogging platform.",
"author" : "Ghost Foundation",
"homepage" : "http://ghost.org",
@ -40,7 +40,7 @@
"express": "3.4.6",
"express-hbs": "0.7.9",
"fs-extra": "0.8.1",
"knex": "0.5.0",
"knex": "0.5.8",
"lodash": "2.4.1",
"moment": "2.4.0",
"node-polyglot": "0.3.0",
@ -48,14 +48,15 @@
"nodemailer": "0.5.13",
"rss": "0.2.1",
"semver": "2.2.1",
"showdown": "0.3.1",
"showdown": "https://github.com/ErisDS/showdown/archive/v0.3.2-ghost.tar.gz",
"sqlite3": "2.2.0",
"unidecode": "0.1.3",
"validator": "3.4.0",
"when": "2.7.0"
"when": "2.7.0",
"xml": "0.0.12"
},
"optionalDependencies": {
"mysql": "2.0.0-alpha9"
"mysql": "2.1.1"
},
"devDependencies": {
"blanket": "~1.1.5",
@ -80,6 +81,7 @@
"grunt-update-submodules": "~0.2.1",
"matchdep": "~0.3.0",
"mocha": "~1.15.1",
"nock": "0.27.2",
"rewire": "~2.0.0",
"request": "~2.29.0",
"require-dir": "~0.1.0",