├── tmp └── .keep ├── sample ├── .gitignore ├── _templates │ ├── draft │ ├── page │ └── post ├── _posts │ ├── 2015-01-30-test.markdown │ └── 2015-01-29-welcome-to-jekyll.markdown ├── _layouts │ ├── page.html │ ├── default.html │ └── post.html ├── about.md ├── index.html ├── _includes │ ├── head.html │ ├── header.html │ └── footer.html ├── css │ └── main.scss ├── feed.xml └── _sass │ ├── _syntax-highlighting.scss │ ├── _base.scss │ └── _layout.scss ├── .rspec ├── .bowerrc ├── app ├── templates │ ├── frontend │ │ └── index.html.erb │ └── application.html.erb ├── views │ ├── frontend │ │ └── index.rb │ ├── application_layout.rb │ ├── posts │ │ ├── upload.rb │ │ ├── show.rb │ │ ├── create.rb │ │ ├── destroy.rb │ │ ├── restore.rb │ │ ├── revert.rb │ │ ├── update.rb │ │ └── index.rb │ ├── version │ │ └── show.rb │ ├── deploys │ │ └── create.rb │ ├── site │ │ └── show.rb │ └── syncs │ │ └── create.rb ├── controllers │ ├── frontend │ │ └── index.rb │ ├── posts │ │ ├── show.rb │ │ ├── revert.rb │ │ ├── destroy.rb │ │ ├── manage.rb │ │ ├── restore.rb │ │ ├── index.rb │ │ ├── create.rb │ │ ├── upload.rb │ │ └── update.rb │ ├── site │ │ └── show.rb │ ├── version │ │ └── show.rb │ ├── deploys │ │ └── create.rb │ └── syncs │ │ └── create.rb ├── config │ ├── environment.rb │ ├── routes.rb │ └── sprockets.rb ├── assets │ ├── javascripts │ │ ├── app.js.coffee │ │ └── components │ │ │ ├── router.js.cjsx │ │ │ ├── header.js.cjsx │ │ │ └── posts.js.cjsx │ └── stylesheets │ │ └── app.scss └── octodmin.rb ├── .lotusrc ├── lib ├── octodmin │ ├── version.rb │ ├── app.rb │ ├── site.rb │ └── post.rb └── octodmin.rb ├── spec ├── fixtures │ └── ear.png ├── api │ ├── version_spec.rb │ ├── site_spec.rb │ ├── deploys_spec.rb │ ├── syncs_spec.rb │ └── posts_spec.rb ├── frontend │ ├── home_spec.rb │ └── assets_spec.rb └── spec_helper.rb ├── config.ru ├── .travis.yml ├── .gitignore ├── bower.json ├── Gemfile ├── _deploy.yml ├── CHANGELOG.md ├── _config.yml ├── Rakefile ├── octodmin.gemspec ├── LICENSE.md └── README.md /tmp/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sample/.gitignore: -------------------------------------------------------------------------------- 1 | _site 2 | .sass-cache 3 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format=documentation 3 | -------------------------------------------------------------------------------- /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory" : "bower_components" 3 | } 4 | -------------------------------------------------------------------------------- /app/templates/frontend/index.html.erb: -------------------------------------------------------------------------------- 1 |
2 | -------------------------------------------------------------------------------- /.lotusrc: -------------------------------------------------------------------------------- 1 | architecture=container 2 | test=minitest 3 | template=erb 4 | -------------------------------------------------------------------------------- /lib/octodmin/version.rb: -------------------------------------------------------------------------------- 1 | module Octodmin 2 | VERSION = "0.3.5" 3 | end 4 | -------------------------------------------------------------------------------- /sample/_templates/draft: -------------------------------------------------------------------------------- 1 | --- 2 | layout: {{ layout }} 3 | title: {{ title }} 4 | --- 5 | -------------------------------------------------------------------------------- /sample/_templates/page: -------------------------------------------------------------------------------- 1 | --- 2 | layout: {{ layout }} 3 | title: {{ title }} 4 | --- 5 | -------------------------------------------------------------------------------- /lib/octodmin/app.rb: -------------------------------------------------------------------------------- 1 | require_relative "../octodmin" 2 | require_relative "../../app/octodmin" 3 | -------------------------------------------------------------------------------- /spec/fixtures/ear.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krasnoukhov/octodmin/HEAD/spec/fixtures/ear.png -------------------------------------------------------------------------------- /app/views/frontend/index.rb: -------------------------------------------------------------------------------- 1 | module Octodmin::Views::Frontend 2 | class Index 3 | include Octodmin::View 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/views/application_layout.rb: -------------------------------------------------------------------------------- 1 | module Octodmin::Views 2 | class ApplicationLayout 3 | include Octodmin::Layout 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | ENV["RACK_ENV"] = ENV["LOTUS_ENV"] ||= "development" 2 | 3 | require "bundler/setup" 4 | require "octodmin/app" 5 | run Octodmin::App.new(__dir__) 6 | -------------------------------------------------------------------------------- /sample/_templates/post: -------------------------------------------------------------------------------- 1 | --- 2 | layout: {{ layout }} 3 | title: {{ title }} 4 | date: {{ date }} 5 | custom: default 6 | --- 7 | 8 | New post from **template** 9 | -------------------------------------------------------------------------------- /lib/octodmin.rb: -------------------------------------------------------------------------------- 1 | require "jekyll" 2 | require "octopress" 3 | require "babosa" 4 | 5 | require "octodmin/version" 6 | require "octodmin/site" 7 | require "octodmin/post" 8 | -------------------------------------------------------------------------------- /app/controllers/frontend/index.rb: -------------------------------------------------------------------------------- 1 | module Octodmin::Controllers::Frontend 2 | class Index 3 | include Octodmin::Action 4 | 5 | def call(params) 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /sample/_posts/2015-01-30-test.markdown: -------------------------------------------------------------------------------- 1 | --- 2 | layout: post 3 | title: "Test" 4 | date: 2015-01-30 18:10:00 5 | custom: data 6 | --- 7 | 8 | # OMG 9 | 10 | This is a *post*! 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: ruby 3 | before_script: 4 | - npm install -g bower 5 | - bower install 6 | script: bundle exec rake spec:coverage 7 | rvm: 8 | - 2.0 9 | - 2.1 10 | - 2.2 11 | -------------------------------------------------------------------------------- /app/config/environment.rb: -------------------------------------------------------------------------------- 1 | require "lotus/setup" 2 | ENV["RACK_ENV"] = ENV["LOTUS_ENV"] ||= "development" 3 | 4 | require_relative "../octodmin" 5 | Lotus::Container.configure do 6 | mount Octodmin::App, at: "/" 7 | end 8 | -------------------------------------------------------------------------------- /app/views/posts/upload.rb: -------------------------------------------------------------------------------- 1 | module Octodmin::Views::Posts 2 | class Upload 3 | include Octodmin::View 4 | format :json 5 | 6 | def render 7 | _raw JSON.dump(uploads: [upload]) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/views/version/show.rb: -------------------------------------------------------------------------------- 1 | module Octodmin::Views::Version 2 | class Show 3 | include Octodmin::View 4 | format :json 5 | 6 | def render 7 | _raw JSON.dump(versions: version) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/views/deploys/create.rb: -------------------------------------------------------------------------------- 1 | module Octodmin::Views::Deploys 2 | class Create 3 | include Octodmin::View 4 | format :json 5 | 6 | def render 7 | _raw JSON.dump(deploys: [message]) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/views/posts/show.rb: -------------------------------------------------------------------------------- 1 | module Octodmin::Views::Posts 2 | class Show 3 | include Octodmin::View 4 | format :json 5 | 6 | def render 7 | _raw JSON.dump(posts: post.serializable_hash) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/views/site/show.rb: -------------------------------------------------------------------------------- 1 | module Octodmin::Views::Site 2 | class Show 3 | include Octodmin::View 4 | format :json 5 | 6 | def render 7 | _raw JSON.dump(sites: site.serializable_hash) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/views/posts/create.rb: -------------------------------------------------------------------------------- 1 | module Octodmin::Views::Posts 2 | class Create 3 | include Octodmin::View 4 | format :json 5 | 6 | def render 7 | _raw JSON.dump(posts: post.serializable_hash) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/views/posts/destroy.rb: -------------------------------------------------------------------------------- 1 | module Octodmin::Views::Posts 2 | class Destroy 3 | include Octodmin::View 4 | format :json 5 | 6 | def render 7 | _raw JSON.dump(posts: post.serializable_hash) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/views/posts/restore.rb: -------------------------------------------------------------------------------- 1 | module Octodmin::Views::Posts 2 | class Restore 3 | include Octodmin::View 4 | format :json 5 | 6 | def render 7 | _raw JSON.dump(posts: post.serializable_hash) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/views/posts/revert.rb: -------------------------------------------------------------------------------- 1 | module Octodmin::Views::Posts 2 | class Revert 3 | include Octodmin::View 4 | format :json 5 | 6 | def render 7 | _raw JSON.dump(posts: post.serializable_hash) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/views/posts/update.rb: -------------------------------------------------------------------------------- 1 | module Octodmin::Views::Posts 2 | class Update 3 | include Octodmin::View 4 | format :json 5 | 6 | def render 7 | _raw JSON.dump(posts: post.serializable_hash) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/views/posts/index.rb: -------------------------------------------------------------------------------- 1 | module Octodmin::Views::Posts 2 | class Index 3 | include Octodmin::View 4 | format :json 5 | 6 | def render 7 | _raw JSON.dump(posts: posts.map(&:serializable_hash)) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/views/syncs/create.rb: -------------------------------------------------------------------------------- 1 | module Octodmin::Views::Syncs 2 | class Create 3 | include Octodmin::View 4 | format :json 5 | 6 | def render 7 | _raw JSON.dump(syncs: [message.gsub("/", "/")]) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/controllers/posts/show.rb: -------------------------------------------------------------------------------- 1 | require_relative "manage" 2 | 3 | module Octodmin::Controllers::Posts 4 | class Show < Manage 5 | include Octodmin::Action 6 | expose :post 7 | 8 | def call(params) 9 | super 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/controllers/site/show.rb: -------------------------------------------------------------------------------- 1 | module Octodmin::Controllers::Site 2 | class Show 3 | include Octodmin::Action 4 | expose :site 5 | 6 | def call(params) 7 | self.format = :json 8 | 9 | @site = Octodmin::Site.new 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/controllers/version/show.rb: -------------------------------------------------------------------------------- 1 | module Octodmin::Controllers::Version 2 | class Show 3 | include Octodmin::Action 4 | expose :version 5 | 6 | def call(params) 7 | self.format = :json 8 | 9 | @version = Octodmin::VERSION 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/controllers/posts/revert.rb: -------------------------------------------------------------------------------- 1 | require_relative "manage" 2 | 3 | module Octodmin::Controllers::Posts 4 | class Revert < Manage 5 | include Octodmin::Action 6 | expose :post 7 | 8 | def call(params) 9 | super 10 | 11 | @post.revert 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /sample/_layouts/page.html: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | --- 4 |
5 | 6 |
7 |

{{ page.title }}

8 |
9 | 10 |
11 | {{ content }} 12 |
13 | 14 |
15 | -------------------------------------------------------------------------------- /app/controllers/posts/destroy.rb: -------------------------------------------------------------------------------- 1 | require_relative "manage" 2 | 3 | module Octodmin::Controllers::Posts 4 | class Destroy < Manage 5 | include Octodmin::Action 6 | expose :post 7 | 8 | def call(params) 9 | super 10 | 11 | @post.delete 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/controllers/posts/manage.rb: -------------------------------------------------------------------------------- 1 | module Octodmin::Controllers::Posts 2 | class Manage 3 | def call(params) 4 | self.format = :json 5 | @post = Octodmin::Post.find(params[:id]) 6 | halt 400, JSON.dump(errors: ["Could not find post"]) unless @post 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/controllers/posts/restore.rb: -------------------------------------------------------------------------------- 1 | require_relative "manage" 2 | 3 | module Octodmin::Controllers::Posts 4 | class Restore < Manage 5 | include Octodmin::Action 6 | expose :post 7 | 8 | def call(params) 9 | super 10 | 11 | @post.restore 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/controllers/posts/index.rb: -------------------------------------------------------------------------------- 1 | module Octodmin::Controllers::Posts 2 | class Index 3 | include Octodmin::Action 4 | expose :posts 5 | 6 | def call(params) 7 | self.format = :json 8 | 9 | site = Octodmin::Site.new 10 | @posts = site.posts 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | tmp 16 | .greenbar 17 | .powrc 18 | .ruby-gemset 19 | .ruby-version 20 | app/public 21 | bower_components 22 | _site 23 | .sass-cache 24 | .deploy 25 | -------------------------------------------------------------------------------- /sample/_layouts/default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% include head.html %} 5 | 6 | 7 | 8 | {% include header.html %} 9 | 10 |
11 |
12 | {{ content }} 13 |
14 |
15 | 16 | {% include footer.html %} 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /spec/api/version_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "version" do 4 | let(:app) { Octodmin::App.new(File.expand_path("../..", __dir__)) } 5 | 6 | describe "show" do 7 | before { get "/api/version" } 8 | subject { parse_json(last_response.body)["versions"] } 9 | 10 | it "returns version" do 11 | expect(subject).to eql(Octodmin::VERSION) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/templates/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Loading... 5 | 6 | 7 | 8 | 9 | 10 | <%= yield %> 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /sample/_layouts/post.html: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | --- 4 |
5 | 6 |
7 |

{{ page.title }}

8 | 9 |
10 | 11 |
12 | {{ content }} 13 |
14 | 15 |
16 | -------------------------------------------------------------------------------- /spec/frontend/home_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "home" do 4 | let(:app) { Octodmin::App.new(File.expand_path("../..", __dir__)) } 5 | 6 | describe "home" do 7 | before { get "/" } 8 | subject { last_response.body } 9 | 10 | it "returns home" do 11 | expect(subject).to include("") 12 | expect(subject).to include("/assets/app.css") 13 | expect(subject).to include("/assets/app.js") 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/assets/javascripts/app.js.coffee: -------------------------------------------------------------------------------- 1 | #= require jquery 2 | #= require jquery-serialize-object 3 | #= require ajaxq 4 | #= require bootstrap 5 | #= require bootstrap-markdown 6 | #= require bootstrap.growl/bootstrap-growl 7 | #= require marked 8 | #= require spin.js/spin.js 9 | #= require react 10 | #= require react-loader 11 | #= require react-router 12 | #= require momentjs/moment 13 | 14 | #= require components/header 15 | #= require components/posts 16 | #= require components/router 17 | -------------------------------------------------------------------------------- /sample/about.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page 3 | title: About 4 | permalink: /about/ 5 | --- 6 | 7 | This is the base Jekyll theme. You can find out more info about customizing your Jekyll theme, as well as basic Jekyll usage documentation at [jekyllrb.com](http://jekyllrb.com/) 8 | 9 | You can find the source code for the Jekyll new theme at: [github.com/jglovier/jekyll-new](https://github.com/jglovier/jekyll-new) 10 | 11 | You can find the source code for Jekyll at [github.com/jekyll/jekyll](https://github.com/jekyll/jekyll) 12 | -------------------------------------------------------------------------------- /app/config/routes.rb: -------------------------------------------------------------------------------- 1 | namespace "api" do 2 | resource :version, only: [:show] 3 | resource :site, only: [:show] 4 | resources :posts do 5 | member do 6 | patch :restore 7 | patch :revert 8 | post :upload 9 | end 10 | end 11 | resources :syncs, only: [:create] 12 | resources :deploys, only: [:create] 13 | end 14 | 15 | if ENV["LOTUS_ENV"] != "production" 16 | mount Octodmin.sprockets, at: "/assets" 17 | end 18 | 19 | get "/posts*", to: "frontend#index" 20 | get "/", to: "frontend#index", as: :home 21 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "octodmin", 3 | "dependencies": { 4 | "jquery": "~2.1.3", 5 | "jquery-serialize-object": "~2.4.5", 6 | "ajaxq": "~0.0.2", 7 | "bootstrap-sass": "~3.3.2", 8 | "bootstrap-markdown": "~2.8.0", 9 | "bootstrap.growl": "~2.0.1", 10 | "marked": "~0.3.3", 11 | "spin.js": "~2.0.2", 12 | "react": "~0.12.0", 13 | "react-loader": "~1.1.0", 14 | "react-router": "~0.12.0", 15 | "fontawesome": "~4.3.0", 16 | "bootswatch": "~3.3.2", 17 | "momentjs": "~2.9.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/controllers/deploys/create.rb: -------------------------------------------------------------------------------- 1 | module Octodmin::Controllers::Deploys 2 | class Create 3 | include Octodmin::Action 4 | expose :message 5 | 6 | def call(params) 7 | self.format = :json 8 | 9 | site = Octodmin::Site.new 10 | site.process 11 | 12 | options = site.config["octodmin"]["deploys"].first 13 | Octopress::Deploy.push(options) 14 | 15 | @message = "Deployed successfully" 16 | rescue SystemExit => e 17 | halt 400, JSON.dump(errors: [e.message]) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gemspec 3 | 4 | gem "rake", "~> 10.0" 5 | gem "sprockets", "~> 3.0.0.beta" 6 | gem "bower", "~> 0.0.2" 7 | gem "sprockets-standalone", github: "krasnoukhov/sprockets-standalone" 8 | gem "coffee-react", "~> 2.4.0" 9 | 10 | group :test do 11 | gem "simplecov", "~> 0.9.0", require: false 12 | gem "coveralls", "~> 0.7.0", require: false 13 | gem "rspec", "~> 3.2.0" 14 | gem "json_spec", "~> 1.1.0" 15 | gem "rack-test", "~> 0.6.0" 16 | end 17 | -------------------------------------------------------------------------------- /spec/api/site_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "site" do 4 | let(:app) { Octodmin::App.new(File.expand_path("../..", __dir__)) } 5 | 6 | describe "show" do 7 | before { get "/api/site" } 8 | subject { parse_json(last_response.body)["sites"] } 9 | 10 | it "returns site" do 11 | expect(subject["title"]).to eql("Your awesome title") 12 | end 13 | 14 | it "returns octodmin config" do 15 | expect(subject["octodmin"]["front_matter"].keys).to eq(["layout", "title", "slug", "date", "custom"]) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/controllers/posts/create.rb: -------------------------------------------------------------------------------- 1 | module Octodmin::Controllers::Posts 2 | class Create 3 | include Octodmin::Action 4 | expose :post 5 | 6 | params do 7 | param :title, presence: true 8 | end 9 | 10 | def call(params) 11 | self.format = :json 12 | halt 400, JSON.dump(errors: ["Required param `title` is not specified"]) unless params.valid? 13 | 14 | @post = Octodmin::Post.create(title: params[:title]) 15 | halt 400, JSON.dump(errors: ["Post with specified `title` already exists"]) unless @post 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/controllers/posts/upload.rb: -------------------------------------------------------------------------------- 1 | require_relative "manage" 2 | 3 | module Octodmin::Controllers::Posts 4 | class Upload < Manage 5 | include Octodmin::Action 6 | expose :upload 7 | 8 | def call(params) 9 | super 10 | 11 | site = Octodmin::Site.new 12 | file = params[:file] 13 | dir = File.join(site.source, "octodmin", @post.identifier) 14 | path = File.join(dir, file["filename"]) 15 | 16 | FileUtils.mkdir_p(dir) 17 | FileUtils.cp(file["tempfile"].path, path) 18 | 19 | @upload = path.sub(site.source, "") 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/frontend/assets_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "assets" do 4 | let(:app) { Octodmin::App.new(File.expand_path("../..", __dir__)) } 5 | 6 | describe "css" do 7 | before { get "/assets/app.css" } 8 | subject { last_response.body } 9 | 10 | it "returns css" do 11 | expect(subject).to include("@charset") 12 | end 13 | end 14 | 15 | describe "js" do 16 | before { get "/assets/app.js" } 17 | subject { last_response.body } 18 | 19 | it "returns js" do 20 | expect(subject).to include("App = React.createClass") 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /sample/index.html: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | --- 4 | 5 |
6 | 7 |

Posts

8 | 9 | 20 | 21 |

subscribe via RSS

22 | 23 |
24 | -------------------------------------------------------------------------------- /_deploy.yml: -------------------------------------------------------------------------------- 1 | method: git # How do you want to deploy? git, rsync or s3. 2 | site_dir: _site # Location of your static site files. 3 | 4 | git_url: git@github.com:krasnoukhov/octodmin.git # remote repository url, e.g. git@github.com:username/repo_name 5 | 6 | # Note on git_branch: 7 | # If using GitHub project pages, set the branch to 'gh-pages'. 8 | # For GitHub user/organization pages or Heroku, set the branch to 'master'. 9 | # 10 | git_branch: gh-pages # Git branch where static site files are commited 11 | 12 | 13 | # remote_path: # Destination directory 14 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | ENV["RACK_ENV"] = ENV["LOTUS_ENV"] = "test" 2 | 3 | require "rubygems" 4 | require "bundler/setup" 5 | 6 | Bundler.require(:test) 7 | require "rack/test" 8 | 9 | if ENV["COVERAGE"] == "true" 10 | require "simplecov" 11 | require "coveralls" 12 | 13 | SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter[ 14 | SimpleCov::Formatter::HTMLFormatter, 15 | Coveralls::SimpleCov::Formatter 16 | ] 17 | 18 | SimpleCov.start do 19 | command_name "spec" 20 | add_filter "spec" 21 | end 22 | end 23 | 24 | $:.unshift "lib" 25 | require "octodmin" 26 | require "octodmin/app" 27 | 28 | RSpec.configure do |config| 29 | config.include Rack::Test::Methods 30 | config.include JsonSpec::Helpers 31 | end 32 | -------------------------------------------------------------------------------- /app/controllers/posts/update.rb: -------------------------------------------------------------------------------- 1 | module Octodmin::Controllers::Posts 2 | class Update 3 | include Octodmin::Action 4 | expose :post 5 | 6 | params do 7 | param :layout, presence: true 8 | param :title, presence: true 9 | param :slug, presence: true 10 | param :date, presence: true 11 | param :content, presence: true 12 | end 13 | 14 | def call(params) 15 | self.format = :json 16 | 17 | @post = Octodmin::Post.find(params.env["router.params"][:id]) 18 | halt 400, JSON.dump(errors: ["Could not find post"]) unless @post 19 | halt 400, JSON.dump(errors: ["Required params are not specified"]) unless params.valid? 20 | @post.update(params.env["rack.request.form_hash"].dup) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | v0.3.5 2 | ------ 3 | 4 | * Handle dates gracefully 5 | 6 | v0.3.4 7 | ------ 8 | 9 | * Use `_raw` when rendering JSON, bump Lotus to 0.3.0 10 | 11 | v0.3.3 12 | ------ 13 | 14 | * Make sync really delete files 15 | 16 | v0.3.2 17 | ------ 18 | 19 | * Disable upload button when file is chosen 20 | * Fix preview layout 21 | * Optimize post initialization 22 | 23 | v0.3.1 24 | ------ 25 | 26 | * Fix staging for those without octodmin dir 27 | 28 | v0.3.0 29 | ------ 30 | 31 | * Improve preview 32 | * Implement file uploads 33 | 34 | v0.2.1 35 | ------ 36 | 37 | * Bring back posts refreshing 38 | 39 | v0.2.0 40 | ------ 41 | 42 | * Support Ruby 2.0 43 | * Disable posts refreshing because of race conditions 44 | * Various UI fixes 45 | 46 | v0.1.0 47 | ------ 48 | 49 | * Initial version 50 | -------------------------------------------------------------------------------- /sample/_includes/head.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {% if page.title %}{{ page.title }}{% else %}{{ site.title }}{% endif %} 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | # Site settings 2 | source: sample 3 | title: Your awesome title 4 | email: your-email@domain.com 5 | description: > # this means to ignore newlines until "baseurl:" 6 | Write an awesome description for your new site here. You can edit this 7 | line in _config.yml. It will appear in your document head meta (for 8 | Google search results) and in your feed.xml site description. 9 | baseurl: "/octodmin" # the subpath of your site, e.g. /blog/ 10 | url: "http://krasnoukhov.github.io" # the base hostname & protocol for your site 11 | twitter_username: jekyllrb 12 | github_username: jekyll 13 | 14 | # Build settings 15 | markdown: kramdown 16 | 17 | # Octopress settings 18 | post_ext: markdown 19 | 20 | # Octodmin settings 21 | octodmin: 22 | transliterate: ukrainian 23 | deploys: 24 | - config_file: _deploy.yml 25 | front_matter: 26 | custom: 27 | type: "text" 28 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "fileutils" 3 | require "rspec/core/rake_task" 4 | require "bundler/gem_tasks" 5 | require "sprockets/standalone" 6 | 7 | # Test tasks 8 | RSpec::Core::RakeTask.new(:spec) 9 | 10 | namespace :spec do 11 | task coverage: :"assets:remove" do 12 | ENV["COVERAGE"] = "true" 13 | Rake::Task["spec"].invoke 14 | end 15 | end 16 | 17 | task default: :spec 18 | 19 | # Release tasks 20 | Sprockets::Standalone::RakeTask.new(:assets) do |task, sprockets| 21 | require_relative "app/config/sprockets" 22 | 23 | task.assets = %w(app.js app.css *.eot *.svg *.ttf *.woff *woff2) 24 | task.sources = %w(app/assets) 25 | task.output = "app/public/assets" 26 | task.compress = true 27 | task.digest = false 28 | task.environment = Octodmin.sprockets 29 | end 30 | 31 | namespace :assets do 32 | task :remove do 33 | FileUtils.rm_r("./tmp/sprockets", force: true) 34 | FileUtils.rm_r("./app/public/assets", force: true) 35 | end 36 | end 37 | 38 | Rake::Task["build"].enhance([:"assets:remove", :"assets:compile"]) 39 | -------------------------------------------------------------------------------- /spec/api/deploys_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "deploys" do 4 | let(:app) { Octodmin::App.new(File.expand_path("../..", __dir__)) } 5 | 6 | before do 7 | expect(Octopress::Deploy).to receive(:push).once 8 | end 9 | 10 | describe "create" do 11 | context "invalid" do 12 | before do 13 | allow(Octopress::Deploy).to receive(:push).and_raise(SystemExit, "Deploy error") 14 | post "/api/deploys" 15 | end 16 | subject { parse_json(last_response.body)["errors"] } 17 | 18 | it "returns errors" do 19 | expect(last_response).to_not be_ok 20 | expect(subject).to eql(["Deploy error"]) 21 | end 22 | end 23 | 24 | context "valid" do 25 | before do 26 | allow(Octopress::Deploy).to receive(:push).and_return(nil) 27 | post "/api/deploys" 28 | end 29 | subject { parse_json(last_response.body)["deploys"] } 30 | 31 | it "returns errors" do 32 | expect(subject).to eql(["Deployed successfully"]) 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /octodmin.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path("../lib", __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require "octodmin/version" 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "octodmin" 8 | spec.version = Octodmin::VERSION 9 | spec.authors = ["Dmitry Krasnoukhov"] 10 | spec.email = ["dmitry@krasnoukhov.com"] 11 | spec.summary = spec.description = %q{Content management for Jekyll blogs} 12 | spec.homepage = "https://github.com/krasnoukhov/octodmin" 13 | spec.license = "MIT" 14 | 15 | spec.files = (`git ls-files -z`.split("\x0") + `find app`.split("\n")).uniq 16 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 17 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 18 | spec.require_paths = ["lib"] 19 | 20 | spec.add_runtime_dependency "octopress", ">= 3.0.0.rc" 21 | spec.add_runtime_dependency "babosa", "~> 1.0" 22 | spec.add_runtime_dependency "lotusrb", ">= 0.3.0" 23 | spec.add_runtime_dependency "git", "~> 1.2.9" 24 | end 25 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Dmitry Krasnoukhov 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /app/config/sprockets.rb: -------------------------------------------------------------------------------- 1 | require "sprockets" 2 | require "coffee_script" 3 | require "coffee-react" 4 | require "bower" 5 | 6 | module Octodmin 7 | module CjsxProcessor 8 | VERSION = '1' 9 | SOURCE_VERSION = ::CoffeeScript::Source.version 10 | 11 | def self.cache_key 12 | @cache_key ||= [name, SOURCE_VERSION, VERSION].freeze 13 | end 14 | 15 | def self.call(input) 16 | data = input[:data] 17 | input[:cache].fetch(self.cache_key + [data]) do 18 | ::CoffeeScript.compile(::CoffeeReact.transform(data)) 19 | end 20 | end 21 | end 22 | 23 | def self.sprockets 24 | assets_path = File.expand_path("../../assets", __FILE__) 25 | tmp_path = File.expand_path("../../../tmp", __FILE__) 26 | 27 | sprockets = ::Sprockets::Environment.new 28 | sprockets.append_path "#{assets_path}/stylesheets" 29 | sprockets.append_path "#{assets_path}/javascripts" 30 | sprockets.append_path "#{assets_path}/fonts" 31 | sprockets.append_path Bower.environment.directory 32 | sprockets.register_engine ".cjsx", CjsxProcessor 33 | sprockets.cache = Sprockets::Cache::FileStore.new(tmp_path) 34 | sprockets 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /sample/_includes/header.html: -------------------------------------------------------------------------------- 1 | 28 | -------------------------------------------------------------------------------- /sample/css/main.scss: -------------------------------------------------------------------------------- 1 | --- 2 | # Only the main Sass file needs front matter (the dashes are enough) 3 | --- 4 | @charset "utf-8"; 5 | 6 | 7 | 8 | // Our variables 9 | $base-font-family: Helvetica, Arial, sans-serif; 10 | $base-font-size: 16px; 11 | $small-font-size: $base-font-size * 0.875; 12 | $base-line-height: 1.5; 13 | 14 | $spacing-unit: 30px; 15 | 16 | $text-color: #111; 17 | $background-color: #fdfdfd; 18 | $brand-color: #2a7ae2; 19 | 20 | $grey-color: #828282; 21 | $grey-color-light: lighten($grey-color, 40%); 22 | $grey-color-dark: darken($grey-color, 25%); 23 | 24 | // Width of the content area 25 | $content-width: 800px; 26 | 27 | $on-palm: 600px; 28 | $on-laptop: 800px; 29 | 30 | 31 | 32 | // Using media queries with like this: 33 | // @include media-query($on-palm) { 34 | // .wrapper { 35 | // padding-right: $spacing-unit / 2; 36 | // padding-left: $spacing-unit / 2; 37 | // } 38 | // } 39 | @mixin media-query($device) { 40 | @media screen and (max-width: $device) { 41 | @content; 42 | } 43 | } 44 | 45 | 46 | 47 | // Import partials from `sass_dir` (defaults to `_sass`) 48 | @import 49 | "base", 50 | "layout", 51 | "syntax-highlighting" 52 | ; 53 | -------------------------------------------------------------------------------- /app/octodmin.rb: -------------------------------------------------------------------------------- 1 | ENV["RACK_ENV"] = ENV["LOTUS_ENV"] ||= "production" 2 | 3 | require "octodmin" 4 | require "lotus" 5 | require "git" 6 | require "json" 7 | require "octopress-deploy" 8 | 9 | begin 10 | require_relative "./config/sprockets" 11 | rescue LoadError 12 | end 13 | 14 | module Octodmin 15 | class App < Lotus::Application 16 | class << self 17 | attr_accessor :dir 18 | end 19 | 20 | configure do 21 | root __dir__ 22 | 23 | routes "config/routes" 24 | load_paths << [ 25 | "controllers", 26 | "views", 27 | ] 28 | 29 | layout :application 30 | end 31 | 32 | # :nocov: 33 | configure :production do 34 | assets << [ 35 | "public", 36 | Octodmin::Site.new.source 37 | ] 38 | serve_assets true 39 | handle_exceptions false 40 | end 41 | 42 | configure :development do 43 | handle_exceptions false 44 | end 45 | # :nocov: 46 | 47 | configure :test do 48 | handle_exceptions false 49 | end 50 | 51 | def initialize(dir = nil) 52 | raise "Please specify root dir" unless dir 53 | raise "Attempt to change root dir" if !self.class.dir.nil? && self.class.dir != dir 54 | 55 | self.class.dir = dir 56 | super() 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /sample/_posts/2015-01-29-welcome-to-jekyll.markdown: -------------------------------------------------------------------------------- 1 | --- 2 | layout: post 3 | title: "Welcome to Jekyll!" 4 | date: 2015-01-29 18:00:17 5 | categories: jekyll update 6 | --- 7 | You’ll find this post in your `_posts` directory. Go ahead and edit it and re-build the site to see your changes. You can rebuild the site in many different ways, but the most common way is to run `jekyll serve`, which launches a web server and auto-regenerates your site when a file is updated. 8 | 9 | To add new posts, simply add a file in the `_posts` directory that follows the convention `YYYY-MM-DD-name-of-post.ext` and includes the necessary front matter. Take a look at the source for this post to get an idea about how it works. 10 | 11 | Jekyll also offers powerful support for code snippets: 12 | 13 | {% highlight ruby %} 14 | def print_hi(name) 15 | puts "Hi, #{name}" 16 | end 17 | print_hi('Tom') 18 | #=> prints 'Hi, Tom' to STDOUT. 19 | {% endhighlight %} 20 | 21 | Check out the [Jekyll docs][jekyll] for more info on how to get the most out of Jekyll. File all bugs/feature requests at [Jekyll’s GitHub repo][jekyll-gh]. If you have questions, you can ask them on [Jekyll’s dedicated Help repository][jekyll-help]. 22 | 23 | [jekyll]: http://jekyllrb.com 24 | [jekyll-gh]: https://github.com/jekyll/jekyll 25 | [jekyll-help]: https://github.com/jekyll/jekyll-help 26 | -------------------------------------------------------------------------------- /lib/octodmin/site.rb: -------------------------------------------------------------------------------- 1 | module Octodmin 2 | class Site 3 | attr_accessor :site 4 | 5 | DEFAULT_CONFIG = { 6 | "octodmin" => { 7 | "transliterate" => "latin", 8 | "front_matter" => { 9 | "layout" => { 10 | "type" => "text", 11 | }, 12 | "title" => { 13 | "type" => "text", 14 | }, 15 | "slug" => { 16 | "type" => "text", 17 | }, 18 | "date" => { 19 | "type" => "text", 20 | }, 21 | }, 22 | }, 23 | } 24 | 25 | def initialize 26 | @site = Jekyll::Site.new(Jekyll.configuration) 27 | end 28 | 29 | def source 30 | @site.source 31 | end 32 | 33 | def config 34 | @config ||= Jekyll::Utils.deep_merge_hashes(DEFAULT_CONFIG, @site.config) 35 | end 36 | 37 | def serializable_hash 38 | config 39 | end 40 | 41 | def posts 42 | reset 43 | @site.read 44 | @site.posts.sort_by(&:date).last(20).reverse.map { |post| Post.new(self, post) } 45 | end 46 | 47 | def reset 48 | @status = nil 49 | @site.reset 50 | self 51 | end 52 | 53 | def status 54 | @status ||= Git.open(Octodmin::App.dir).status 55 | end 56 | 57 | def process 58 | @site.process 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /sample/feed.xml: -------------------------------------------------------------------------------- 1 | --- 2 | layout: null 3 | --- 4 | 5 | 6 | 7 | {{ site.title | xml_escape }} 8 | {{ site.description | xml_escape }} 9 | {{ site.url }}{{ site.baseurl }}/ 10 | 11 | {{ site.time | date_to_rfc822 }} 12 | {{ site.time | date_to_rfc822 }} 13 | Jekyll v{{ jekyll.version }} 14 | {% for post in site.posts limit:10 %} 15 | 16 | {{ post.title | xml_escape }} 17 | {{ post.content | xml_escape }} 18 | {{ post.date | date_to_rfc822 }} 19 | {{ post.url | prepend: site.baseurl | prepend: site.url }} 20 | {{ post.url | prepend: site.baseurl | prepend: site.url }} 21 | {% for tag in post.tags %} 22 | {{ tag | xml_escape }} 23 | {% endfor %} 24 | {% for cat in post.categories %} 25 | {{ cat | xml_escape }} 26 | {% endfor %} 27 | 28 | {% endfor %} 29 | 30 | 31 | -------------------------------------------------------------------------------- /app/assets/stylesheets/app.scss: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | $fa-font-path: "/assets/fontawesome/fonts"; 3 | @import "bootswatch/paper/variables"; 4 | $icon-font-path: "/assets/bootstrap/fonts/"; 5 | //= require bootstrap-markdown 6 | @import 7 | "bootstrap-sass/assets/stylesheets/bootstrap", 8 | "bootswatch/paper/bootswatch", 9 | "fontawesome/scss/font-awesome" 10 | ; 11 | 12 | body { 13 | padding-bottom: 20px; 14 | } 15 | 16 | .growl-animated { 17 | padding: 10px 35px 10px 15px; 18 | } 19 | 20 | a, button, input, textarea, *:focus { 21 | outline: 0 !important; 22 | outline-style: none !important; 23 | } 24 | 25 | .navbar { 26 | margin-bottom: 20px; 27 | 28 | .navbar-right { 29 | margin-right: 0; 30 | 31 | .btn { 32 | margin-left: 10px; 33 | } 34 | } 35 | } 36 | 37 | .panel { 38 | &:not(.panel-default) { 39 | .panel-heading { 40 | color: #fff; 41 | } 42 | } 43 | 44 | .panel-heading { 45 | line-height: 26px; 46 | 47 | h3 { 48 | font-size: 26px; 49 | } 50 | } 51 | 52 | .panel-body { 53 | .excerpt { 54 | :first-child { 55 | margin-top: 0; 56 | } 57 | 58 | :last-child { 59 | margin-bottom: 0; 60 | } 61 | } 62 | 63 | .buttons { 64 | text-align: center; 65 | } 66 | } 67 | } 68 | 69 | .post-edit { 70 | .md-editor { 71 | .md-header { 72 | margin-bottom: 15px; 73 | } 74 | } 75 | 76 | .md-content { 77 | width: 100%; 78 | } 79 | 80 | .md-preview { 81 | overflow: auto; 82 | } 83 | 84 | .buttons { 85 | .btn { 86 | margin-left: 10px; 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /app/assets/javascripts/components/router.js.cjsx: -------------------------------------------------------------------------------- 1 | # @cjsx React.DOM 2 | 3 | @Router = ReactRouter 4 | @Link = Router.Link 5 | @Routes = Router.Routes 6 | @Route = Router.Route 7 | @DefaultRoute = Router.DefaultRoute 8 | @RouteHandler = Router.RouteHandler 9 | 10 | @growlSuccess = 11 | type: "success" 12 | delay: 10000 13 | 14 | @growlError = 15 | type: "danger" 16 | delay: 60000 17 | 18 | @Container = React.createClass( 19 | render: -> 20 |
21 | {@props.children} 22 |
23 | ) 24 | 25 | @App = React.createClass( 26 | getInitialState: -> 27 | { site: null } 28 | 29 | fetchSite: -> 30 | $.getq("default", "/api/site").done(@handleSuccess).fail(@handleError) 31 | 32 | handleSuccess: (response) -> 33 | @setState(site: response.sites) 34 | $("title").text("Octodmin – #{response.sites.title}") 35 | 36 | handleError: (error) -> 37 | $.growl("Could not load site: #{error.statusText} (#{error.status})", growlError) 38 | 39 | componentWillMount: -> 40 | @fetchSite() 41 | $(document).on("fetchSite", @fetchSite) 42 | 43 | componentWillUnmount: -> 44 | $(document).off("fetchSite", @fetchSite) 45 | 46 | render: -> 47 | 48 | 49 |
50 | 51 | 52 | 53 | ) 54 | 55 | routes = 56 | 57 | 58 | 59 | 60 | 61 | Router.run(routes, Router.HistoryLocation, (Handler) -> 62 | React.render(, document.getElementById("app")) 63 | ) 64 | -------------------------------------------------------------------------------- /app/controllers/syncs/create.rb: -------------------------------------------------------------------------------- 1 | module Octodmin::Controllers::Syncs 2 | class Create 3 | include Octodmin::Action 4 | expose :message 5 | 6 | def call(params) 7 | self.format = :json 8 | 9 | site = Octodmin::Site.new 10 | git = Git.open(Octodmin::App.dir) 11 | 12 | # Add files to commit stage 13 | stage(site, git) 14 | 15 | # Pull changes 16 | git.pull 17 | 18 | # Commit and push changes if any 19 | sync(site, git) 20 | rescue Git::GitExecuteError => e 21 | halt 400, JSON.dump(errors: [e.message]) 22 | ensure 23 | git.reset(".") 24 | end 25 | 26 | private 27 | 28 | def deleted(site) 29 | site.status.deleted.keys.map { |path| File.join(Octodmin::App.dir, path) } 30 | end 31 | 32 | def stage(site, git) 33 | # Posts 34 | site.posts.each do |post| 35 | path = File.join(site.source, post.path) 36 | git.add(path) unless deleted(site).include?(path) 37 | end 38 | 39 | # Uploads 40 | dir = File.join(site.source, "octodmin") 41 | git.add(dir) if Dir.exists?(dir) 42 | end 43 | 44 | def paths(site, git) 45 | status = site.reset.status 46 | keys = status.changed.keys + status.added.keys + status.deleted.keys 47 | 48 | keys.sort.reverse.map do |path| 49 | File.join(Octodmin::App.dir, path).sub(File.join(site.source, ""), "") 50 | end 51 | end 52 | 53 | def sync(site, git) 54 | staged = paths(site, git) 55 | 56 | if staged.any? 57 | @message = "Octodmin sync for #{staged.count} file#{"s" if staged.count > 1}" 58 | @message += "\n\n#{staged.join("\n")}" 59 | 60 | deleted(site).each { |path| File.delete(path) } 61 | git.commit(@message) 62 | git.push 63 | else 64 | @message = "Everything is up-to-date" 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /app/assets/javascripts/components/header.js.cjsx: -------------------------------------------------------------------------------- 1 | # @cjsx React.DOM 2 | 3 | @Header = React.createClass( 4 | propTypes: 5 | site: React.PropTypes.object.isRequired 6 | 7 | getInitialState: -> 8 | { loading: false } 9 | 10 | handleSync: -> 11 | return if @state.loading 12 | @setState(loading: true) 13 | $.postq("default", "/api/syncs").always(@handleResponse).done(@handleSyncSuccess).fail(@handleError) 14 | 15 | handleDeploy: -> 16 | return if @state.loading 17 | @setState(loading: true) 18 | $.postq("default", "/api/deploys").always(@handleResponse).done(@handleDeploySuccess).fail(@handleError) 19 | 20 | handleResponse: -> 21 | @setState(loading: false) 22 | 23 | handleSyncSuccess: (response) -> 24 | $.growl(response.syncs.join("\n").replace(/\n/g, "
"), growlSuccess) 25 | $(document).trigger("fetchPosts") 26 | 27 | handleDeploySuccess: (response) -> 28 | $.growl(response.deploys.join("\n"), growlSuccess) 29 | $(document).trigger("fetchPosts") 30 | 31 | handleError: (error) -> 32 | $.growl(error.responseJSON?.errors.join("\n").replace(/\n/g, "
") || error.statusText, growlError) 33 | 34 | render: -> 35 |
36 | 58 |
59 | ) 60 | -------------------------------------------------------------------------------- /sample/_includes/footer.html: -------------------------------------------------------------------------------- 1 | 56 | -------------------------------------------------------------------------------- /spec/api/syncs_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "syncs" do 4 | let(:date) { Date.today.strftime("%Y-%m-%d") } 5 | let(:app) { Octodmin::App.new(File.expand_path("../..", __dir__)) } 6 | 7 | before do 8 | allow_any_instance_of(Git::Base).to receive(:pull).and_return(nil) 9 | allow_any_instance_of(Git::Base).to receive(:push).and_return(nil) 10 | end 11 | 12 | describe "create" do 13 | context "invalid" do 14 | before do 15 | allow_any_instance_of(Git::Base).to receive(:pull).and_raise(Git::GitExecuteError, "Git error") 16 | expect_any_instance_of(Git::Base).to receive(:pull).once 17 | 18 | post "/api/posts", title: "Blah" 19 | post "/api/syncs" 20 | end 21 | after { File.delete("sample/_posts/#{date}-blah.markdown") } 22 | subject { parse_json(last_response.body)["errors"] } 23 | 24 | it "returns errors" do 25 | expect(last_response).to_not be_ok 26 | expect(subject).to eql(["Git error"]) 27 | end 28 | end 29 | 30 | context "valid" do 31 | context "no changes" do 32 | before do 33 | expect_any_instance_of(Git::Base).to receive(:pull).once 34 | post "/api/syncs" 35 | end 36 | subject { parse_json(last_response.body)["syncs"] } 37 | 38 | it "returns errors" do 39 | expect(subject).to eql(["Everything is up-to-date"]) 40 | end 41 | end 42 | 43 | context "with changes" do 44 | before do 45 | allow_any_instance_of(Git::Base).to receive(:commit).and_return(nil) 46 | expect_any_instance_of(Git::Base).to receive(:pull).once 47 | expect_any_instance_of(Git::Base).to receive(:commit).once 48 | expect_any_instance_of(Git::Base).to receive(:push).once 49 | 50 | # Create post 51 | post "/api/posts", title: "Yo" 52 | 53 | # Update post 54 | patch "/api/posts/2015-01-30-test", { 55 | layout: "other", 56 | title: "Test", 57 | slug: "test", 58 | date: "2015-01-30 18:10:00", 59 | content: "### WOW", 60 | } 61 | 62 | # Delete post 63 | delete "/api/posts/2015-01-29-welcome-to-jekyll" 64 | 65 | # Upload file 66 | post "/api/posts/2015-01-30-test/upload", { 67 | file: Rack::Test::UploadedFile.new("spec/fixtures/ear.png") 68 | } 69 | 70 | post "/api/syncs" 71 | end 72 | after do 73 | File.delete("sample/_posts/#{date}-yo.markdown") 74 | git = Git.open(Octodmin::App.dir) 75 | git.checkout("sample/_posts/2015-01-30-test.markdown") 76 | git.checkout("sample/_posts/2015-01-29-welcome-to-jekyll.markdown") 77 | File.delete("sample/octodmin/2015-01-30-test/ear.png") 78 | end 79 | subject { parse_json(last_response.body)["syncs"] } 80 | 81 | it "returns syncs" do 82 | expect(File.exists?("sample/_posts/2015-01-29-welcome-to-jekyll.markdown")).to be_falsey 83 | 84 | expect(subject).to eql([[ 85 | "Octodmin sync for 4 files\n", 86 | "octodmin/2015-01-30-test/ear.png", 87 | "_posts/#{date}-yo.markdown", 88 | "_posts/2015-01-30-test.markdown", 89 | "_posts/2015-01-29-welcome-to-jekyll.markdown", 90 | ].join("\n")]) 91 | end 92 | end 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /sample/_sass/_syntax-highlighting.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Syntax highlighting styles 3 | */ 4 | .highlight { 5 | background: #fff; 6 | @extend %vertical-rhythm; 7 | 8 | .c { color: #998; font-style: italic } // Comment 9 | .err { color: #a61717; background-color: #e3d2d2 } // Error 10 | .k { font-weight: bold } // Keyword 11 | .o { font-weight: bold } // Operator 12 | .cm { color: #998; font-style: italic } // Comment.Multiline 13 | .cp { color: #999; font-weight: bold } // Comment.Preproc 14 | .c1 { color: #998; font-style: italic } // Comment.Single 15 | .cs { color: #999; font-weight: bold; font-style: italic } // Comment.Special 16 | .gd { color: #000; background-color: #fdd } // Generic.Deleted 17 | .gd .x { color: #000; background-color: #faa } // Generic.Deleted.Specific 18 | .ge { font-style: italic } // Generic.Emph 19 | .gr { color: #a00 } // Generic.Error 20 | .gh { color: #999 } // Generic.Heading 21 | .gi { color: #000; background-color: #dfd } // Generic.Inserted 22 | .gi .x { color: #000; background-color: #afa } // Generic.Inserted.Specific 23 | .go { color: #888 } // Generic.Output 24 | .gp { color: #555 } // Generic.Prompt 25 | .gs { font-weight: bold } // Generic.Strong 26 | .gu { color: #aaa } // Generic.Subheading 27 | .gt { color: #a00 } // Generic.Traceback 28 | .kc { font-weight: bold } // Keyword.Constant 29 | .kd { font-weight: bold } // Keyword.Declaration 30 | .kp { font-weight: bold } // Keyword.Pseudo 31 | .kr { font-weight: bold } // Keyword.Reserved 32 | .kt { color: #458; font-weight: bold } // Keyword.Type 33 | .m { color: #099 } // Literal.Number 34 | .s { color: #d14 } // Literal.String 35 | .na { color: #008080 } // Name.Attribute 36 | .nb { color: #0086B3 } // Name.Builtin 37 | .nc { color: #458; font-weight: bold } // Name.Class 38 | .no { color: #008080 } // Name.Constant 39 | .ni { color: #800080 } // Name.Entity 40 | .ne { color: #900; font-weight: bold } // Name.Exception 41 | .nf { color: #900; font-weight: bold } // Name.Function 42 | .nn { color: #555 } // Name.Namespace 43 | .nt { color: #000080 } // Name.Tag 44 | .nv { color: #008080 } // Name.Variable 45 | .ow { font-weight: bold } // Operator.Word 46 | .w { color: #bbb } // Text.Whitespace 47 | .mf { color: #099 } // Literal.Number.Float 48 | .mh { color: #099 } // Literal.Number.Hex 49 | .mi { color: #099 } // Literal.Number.Integer 50 | .mo { color: #099 } // Literal.Number.Oct 51 | .sb { color: #d14 } // Literal.String.Backtick 52 | .sc { color: #d14 } // Literal.String.Char 53 | .sd { color: #d14 } // Literal.String.Doc 54 | .s2 { color: #d14 } // Literal.String.Double 55 | .se { color: #d14 } // Literal.String.Escape 56 | .sh { color: #d14 } // Literal.String.Heredoc 57 | .si { color: #d14 } // Literal.String.Interpol 58 | .sx { color: #d14 } // Literal.String.Other 59 | .sr { color: #009926 } // Literal.String.Regex 60 | .s1 { color: #d14 } // Literal.String.Single 61 | .ss { color: #990073 } // Literal.String.Symbol 62 | .bp { color: #999 } // Name.Builtin.Pseudo 63 | .vc { color: #008080 } // Name.Variable.Class 64 | .vg { color: #008080 } // Name.Variable.Global 65 | .vi { color: #008080 } // Name.Variable.Instance 66 | .il { color: #099 } // Literal.Number.Integer.Long 67 | } 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Octodmin 2 | 3 | Content management for Jekyll blogs 4 | 5 | [![Gem Version](https://badge.fury.io/rb/octodmin.svg)](http://badge.fury.io/rb/octodmin) 6 | [![Build Status](https://secure.travis-ci.org/krasnoukhov/octodmin.svg?branch=master)](http://travis-ci.org/krasnoukhov/octodmin?branch=master) 7 | [![Coverage Status](https://img.shields.io/coveralls/krasnoukhov/octodmin.svg)](https://coveralls.io/r/krasnoukhov/octodmin?branch=master) 8 | [![Code Climate](https://img.shields.io/codeclimate/github/krasnoukhov/octodmin.svg)](https://codeclimate.com/github/krasnoukhov/octodmin) 9 | [![Dependency Status](https://gemnasium.com/krasnoukhov/octodmin.svg)](https://gemnasium.com/krasnoukhov/octodmin) 10 | 11 | ## Installation 12 | 13 | Add this line to your Jekyll project's Gemfile: 14 | 15 | ```ruby 16 | gem 'octodmin' 17 | ``` 18 | 19 | And then execute: 20 | 21 | ```bash 22 | $ bundle 23 | ``` 24 | 25 | ## Usage 26 | 27 | Octodmin assumes that there is a `_config.yml` with Jekyll 28 | configuration. 29 | 30 | Add `config.ru` to your project: 31 | 32 | ```ruby 33 | require "octodmin/app" 34 | run Octodmin::App.new(__dir__) 35 | ``` 36 | 37 | Run it as a Rack application: 38 | 39 | ```bash 40 | $ rackup 41 | ``` 42 | 43 | ## Configuration 44 | 45 | Octodmin can be configured using `_config.yml`. For example: 46 | 47 | ```yaml 48 | # Octodmin settings 49 | octodmin: 50 | transliterate: ukrainian 51 | deploys: 52 | - config_file: _deploy.yml 53 | front_matter: 54 | custom: 55 | type: "text" 56 | ``` 57 | 58 | Valid options: 59 | 60 | `transliterate`: use any of [babosa](https://github.com/norman/babosa#locale-sensitive-transliteration-with-support-for-many-languages)'s 61 | languages for slug transliteration. Default is `latin` 62 | 63 | `deploys`: if you use `octopress-deploy`, specify your deploy configuration 64 | file to get a "Deploy" button in Octodmin. 65 | 66 | `front_matter`: if you use custom front matter attributes, specify all 67 | of them to extend the edit form with corresponding inputs. 68 | 69 | Please note that Octodmin uses Octopress internally, so make sure you configure it 70 | accordingly. For example: 71 | 72 | ```yaml 73 | # Octopress 74 | post_ext: markdown 75 | post_layout: post 76 | ``` 77 | 78 | ## Deployment 79 | 80 | Since Octodmin is a simple Rack app, use your favorite Ruby application server. 81 | For example, add `puma` to Gemfile, run `bundle`, and then `rackup`. 82 | That's it. 83 | 84 | When deploying Octodmin to a remote server, make sure you're able to run 85 | `git pull`, `git push` and `octopress deploy` (if needed) successfully 86 | in the shell of your remote user. 87 | 88 | For basic HTTP authentication, use `Rack::Auth::Basic`. 89 | Example for your `config.ru`: 90 | 91 | ```ruby 92 | use Rack::Auth::Basic, "Octodmin" do |username, password| 93 | [username, password] == [ENV["USERNAME"], ENV["PASSWORD"]] 94 | end 95 | ``` 96 | 97 | Just set ENV variables and you're good to go. 98 | 99 | ## Development and testing 100 | 101 | You would need `npm` and `bower`. Run `bower install` to install asset 102 | dependencies. 103 | 104 | Run `rackup` to start the development server. 105 | 106 | Run `rspec` to run tests. 107 | 108 | ## Contributing 109 | 110 | 1. Fork it 111 | 2. Create your feature branch (`git checkout -b my-new-feature`) 112 | 3. Commit your changes (`git commit -am 'Add some feature'`) 113 | 4. Push to the branch (`git push origin my-new-feature`) 114 | 5. Create new Pull Request 115 | 116 | ## LICENSE 117 | 118 | The MIT License 119 | -------------------------------------------------------------------------------- /sample/_sass/_base.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Reset some basic elements 3 | */ 4 | body, h1, h2, h3, h4, h5, h6, 5 | p, blockquote, pre, hr, 6 | dl, dd, ol, ul, figure { 7 | margin: 0; 8 | padding: 0; 9 | } 10 | 11 | 12 | 13 | /** 14 | * Basic styling 15 | */ 16 | body { 17 | font-family: $base-font-family; 18 | font-size: $base-font-size; 19 | line-height: $base-line-height; 20 | font-weight: 300; 21 | color: $text-color; 22 | background-color: $background-color; 23 | -webkit-text-size-adjust: 100%; 24 | } 25 | 26 | 27 | 28 | /** 29 | * Set `margin-bottom` to maintain vertical rhythm 30 | */ 31 | h1, h2, h3, h4, h5, h6, 32 | p, blockquote, pre, 33 | ul, ol, dl, figure, 34 | %vertical-rhythm { 35 | margin-bottom: $spacing-unit / 2; 36 | } 37 | 38 | 39 | 40 | /** 41 | * Images 42 | */ 43 | img { 44 | max-width: 100%; 45 | vertical-align: middle; 46 | } 47 | 48 | 49 | 50 | /** 51 | * Figures 52 | */ 53 | figure > img { 54 | display: block; 55 | } 56 | 57 | figcaption { 58 | font-size: $small-font-size; 59 | } 60 | 61 | 62 | 63 | /** 64 | * Lists 65 | */ 66 | ul, ol { 67 | margin-left: $spacing-unit; 68 | } 69 | 70 | li { 71 | > ul, 72 | > ol { 73 | margin-bottom: 0; 74 | } 75 | } 76 | 77 | 78 | 79 | /** 80 | * Headings 81 | */ 82 | h1, h2, h3, h4, h5, h6 { 83 | font-weight: 300; 84 | } 85 | 86 | 87 | 88 | /** 89 | * Links 90 | */ 91 | a { 92 | color: $brand-color; 93 | text-decoration: none; 94 | 95 | &:visited { 96 | color: darken($brand-color, 15%); 97 | } 98 | 99 | &:hover { 100 | color: $text-color; 101 | text-decoration: underline; 102 | } 103 | } 104 | 105 | 106 | 107 | /** 108 | * Blockquotes 109 | */ 110 | blockquote { 111 | color: $grey-color; 112 | border-left: 4px solid $grey-color-light; 113 | padding-left: $spacing-unit / 2; 114 | font-size: 18px; 115 | letter-spacing: -1px; 116 | font-style: italic; 117 | 118 | > :last-child { 119 | margin-bottom: 0; 120 | } 121 | } 122 | 123 | 124 | 125 | /** 126 | * Code formatting 127 | */ 128 | pre, 129 | code { 130 | font-size: 15px; 131 | border: 1px solid $grey-color-light; 132 | border-radius: 3px; 133 | background-color: #eef; 134 | } 135 | 136 | code { 137 | padding: 1px 5px; 138 | } 139 | 140 | pre { 141 | padding: 8px 12px; 142 | overflow-x: scroll; 143 | 144 | > code { 145 | border: 0; 146 | padding-right: 0; 147 | padding-left: 0; 148 | } 149 | } 150 | 151 | 152 | 153 | /** 154 | * Wrapper 155 | */ 156 | .wrapper { 157 | max-width: -webkit-calc(#{$content-width} - (#{$spacing-unit} * 2)); 158 | max-width: calc(#{$content-width} - (#{$spacing-unit} * 2)); 159 | margin-right: auto; 160 | margin-left: auto; 161 | padding-right: $spacing-unit; 162 | padding-left: $spacing-unit; 163 | @extend %clearfix; 164 | 165 | @include media-query($on-laptop) { 166 | max-width: -webkit-calc(#{$content-width} - (#{$spacing-unit})); 167 | max-width: calc(#{$content-width} - (#{$spacing-unit})); 168 | padding-right: $spacing-unit / 2; 169 | padding-left: $spacing-unit / 2; 170 | } 171 | } 172 | 173 | 174 | 175 | /** 176 | * Clearfix 177 | */ 178 | %clearfix { 179 | 180 | &:after { 181 | content: ""; 182 | display: table; 183 | clear: both; 184 | } 185 | } 186 | 187 | 188 | 189 | /** 190 | * Icons 191 | */ 192 | .icon { 193 | 194 | > svg { 195 | display: inline-block; 196 | width: 16px; 197 | height: 16px; 198 | vertical-align: middle; 199 | 200 | path { 201 | fill: $grey-color; 202 | } 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /lib/octodmin/post.rb: -------------------------------------------------------------------------------- 1 | module Octodmin 2 | class Post 3 | attr_accessor :post 4 | 5 | ATTRIBUTES_FOR_SERIALIZAION = Jekyll::Post::ATTRIBUTES_FOR_LIQUID - %w[ 6 | previous 7 | next 8 | ] 9 | 10 | def self.find(id) 11 | site = Octodmin::Site.new 12 | site.posts.find { |post| post.identifier == id } 13 | end 14 | 15 | def self.create(options = {}) 16 | site = Octodmin::Site.new 17 | 18 | # Prepare slug 19 | options["slug"] = slug_for(site, options[:title]) 20 | 21 | # Create post 22 | post = Octopress::Post.new(Octopress.site, Jekyll::Utils.stringify_hash_keys(options)) 23 | post.write 24 | 25 | site.posts.first 26 | rescue RuntimeError 27 | end 28 | 29 | def initialize(site, post) 30 | @site = site 31 | @post = post 32 | end 33 | 34 | def path 35 | @post.path 36 | end 37 | 38 | def identifier 39 | path.split("/").last.split(".").first 40 | end 41 | 42 | def serializable_hash 43 | hash = @post.to_liquid(ATTRIBUTES_FOR_SERIALIZAION).dup 44 | 45 | excerpt = Jekyll::Excerpt.new(@post) 46 | excerpt.do_layout({ "site" => @site.config }, {}) 47 | 48 | hash.merge( 49 | excerpt: excerpt.content, 50 | identifier: identifier, 51 | slug: @post.slug, 52 | added: added?, 53 | changed: changed?, 54 | deleted: deleted?, 55 | ) 56 | end 57 | 58 | def update(params) 59 | # Remove old post 60 | delete 61 | 62 | # Init the new one 63 | octopost = Octopress::Post.new(Octopress.site, { 64 | "path" => @post.path, 65 | "date" => params["date"], 66 | "slug" => params["slug"], 67 | "title" => params["title"], 68 | "force" => true, 69 | }) 70 | 71 | options = Hash[@site.config["octodmin"]["front_matter"].keys.map do |key| 72 | [key, params[key]] 73 | end] 74 | 75 | content = params["content"].gsub("\r\n", "\n").strip 76 | result = "---\n#{options.map { |k, v| "#{k}: \"#{v}\"" }.join("\n")}\n---\n\n#{content}\n" 77 | octopost.instance_variable_set(:@content, result) 78 | octopost.write 79 | 80 | @post = post_for(octopost) 81 | 82 | # Add/reset post just in case 83 | git = Git.open(Octodmin::App.dir) 84 | git.add(octopost.path) 85 | git.reset(octopost.path) 86 | end 87 | 88 | def delete 89 | octopost = octopost_for(@post) 90 | git = Git.open(Octodmin::App.dir) 91 | git.lib.send(:command, "rm", ["-f", "--cached", octopost.path]) 92 | rescue Git::GitExecuteError 93 | File.delete(octopost.path) 94 | end 95 | 96 | def restore 97 | return unless deleted? 98 | 99 | octopost = octopost_for(@post) 100 | git = Git.open(Octodmin::App.dir) 101 | git.add(octopost.path) 102 | 103 | @site.reset 104 | @post = post_for(octopost) 105 | end 106 | 107 | def revert 108 | return unless changed? 109 | 110 | octopost = octopost_for(@post) 111 | git = Git.open(Octodmin::App.dir) 112 | git.reset(octopost.path) 113 | git.checkout(octopost.path) 114 | 115 | @site.reset 116 | @post = post_for(octopost) 117 | end 118 | 119 | private 120 | 121 | def self.slug_for(site, title) 122 | title.to_slug.transliterate( 123 | site.config["octodmin"]["transliterate"] 124 | ).normalize.to_s 125 | end 126 | 127 | def octopost_for(post) 128 | Octopress::Post.new(Octopress.site, { 129 | "path" => post.path, 130 | "date" => post.date, 131 | "slug" => post.slug, 132 | "title" => post.title, 133 | }) 134 | end 135 | 136 | def post_for(octopost) 137 | @site.posts.find do |post| 138 | File.join(@site.site.source, post.post.path) == octopost.path 139 | end.post 140 | end 141 | 142 | def has_status?(post, status) 143 | path = File.join(@site.source, post.path) 144 | changed = @site.status.public_send(status).keys 145 | paths = changed.map { |path| File.join(Octodmin::App.dir, path) } 146 | paths.include?(path) 147 | end 148 | 149 | def added? 150 | has_status?(@post, :untracked) && !changed? && !deleted? 151 | end 152 | 153 | def changed? 154 | has_status?(@post, :changed) 155 | end 156 | 157 | def deleted? 158 | has_status?(@post, :deleted) 159 | end 160 | end 161 | end 162 | -------------------------------------------------------------------------------- /sample/_sass/_layout.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Site header 3 | */ 4 | .site-header { 5 | border-top: 5px solid $grey-color-dark; 6 | border-bottom: 1px solid $grey-color-light; 7 | min-height: 56px; 8 | 9 | // Positioning context for the mobile navigation icon 10 | position: relative; 11 | } 12 | 13 | .site-title { 14 | font-size: 26px; 15 | line-height: 56px; 16 | letter-spacing: -1px; 17 | margin-bottom: 0; 18 | float: left; 19 | 20 | &, 21 | &:visited { 22 | color: $grey-color-dark; 23 | } 24 | } 25 | 26 | .site-nav { 27 | float: right; 28 | line-height: 56px; 29 | 30 | .menu-icon { 31 | display: none; 32 | } 33 | 34 | .page-link { 35 | color: $text-color; 36 | line-height: $base-line-height; 37 | 38 | // Gaps between nav items, but not on the first one 39 | &:not(:first-child) { 40 | margin-left: 20px; 41 | } 42 | } 43 | 44 | @include media-query($on-palm) { 45 | position: absolute; 46 | top: 9px; 47 | right: 30px; 48 | background-color: $background-color; 49 | border: 1px solid $grey-color-light; 50 | border-radius: 5px; 51 | text-align: right; 52 | 53 | .menu-icon { 54 | display: block; 55 | float: right; 56 | width: 36px; 57 | height: 26px; 58 | line-height: 0; 59 | padding-top: 10px; 60 | text-align: center; 61 | 62 | > svg { 63 | width: 18px; 64 | height: 15px; 65 | 66 | path { 67 | fill: $grey-color-dark; 68 | } 69 | } 70 | } 71 | 72 | .trigger { 73 | clear: both; 74 | display: none; 75 | } 76 | 77 | &:hover .trigger { 78 | display: block; 79 | padding-bottom: 5px; 80 | } 81 | 82 | .page-link { 83 | display: block; 84 | padding: 5px 10px; 85 | } 86 | } 87 | } 88 | 89 | 90 | 91 | /** 92 | * Site footer 93 | */ 94 | .site-footer { 95 | border-top: 1px solid $grey-color-light; 96 | padding: $spacing-unit 0; 97 | } 98 | 99 | .footer-heading { 100 | font-size: 18px; 101 | margin-bottom: $spacing-unit / 2; 102 | } 103 | 104 | .contact-list, 105 | .social-media-list { 106 | list-style: none; 107 | margin-left: 0; 108 | } 109 | 110 | .footer-col-wrapper { 111 | font-size: 15px; 112 | color: $grey-color; 113 | margin-left: -$spacing-unit / 2; 114 | @extend %clearfix; 115 | } 116 | 117 | .footer-col { 118 | float: left; 119 | margin-bottom: $spacing-unit / 2; 120 | padding-left: $spacing-unit / 2; 121 | } 122 | 123 | .footer-col-1 { 124 | width: -webkit-calc(35% - (#{$spacing-unit} / 2)); 125 | width: calc(35% - (#{$spacing-unit} / 2)); 126 | } 127 | 128 | .footer-col-2 { 129 | width: -webkit-calc(20% - (#{$spacing-unit} / 2)); 130 | width: calc(20% - (#{$spacing-unit} / 2)); 131 | } 132 | 133 | .footer-col-3 { 134 | width: -webkit-calc(45% - (#{$spacing-unit} / 2)); 135 | width: calc(45% - (#{$spacing-unit} / 2)); 136 | } 137 | 138 | @include media-query($on-laptop) { 139 | .footer-col-1, 140 | .footer-col-2 { 141 | width: -webkit-calc(50% - (#{$spacing-unit} / 2)); 142 | width: calc(50% - (#{$spacing-unit} / 2)); 143 | } 144 | 145 | .footer-col-3 { 146 | width: -webkit-calc(100% - (#{$spacing-unit} / 2)); 147 | width: calc(100% - (#{$spacing-unit} / 2)); 148 | } 149 | } 150 | 151 | @include media-query($on-palm) { 152 | .footer-col { 153 | float: none; 154 | width: -webkit-calc(100% - (#{$spacing-unit} / 2)); 155 | width: calc(100% - (#{$spacing-unit} / 2)); 156 | } 157 | } 158 | 159 | 160 | 161 | /** 162 | * Page content 163 | */ 164 | .page-content { 165 | padding: $spacing-unit 0; 166 | } 167 | 168 | .page-heading { 169 | font-size: 20px; 170 | } 171 | 172 | .post-list { 173 | margin-left: 0; 174 | list-style: none; 175 | 176 | > li { 177 | margin-bottom: $spacing-unit; 178 | } 179 | } 180 | 181 | .post-meta { 182 | font-size: $small-font-size; 183 | color: $grey-color; 184 | } 185 | 186 | .post-link { 187 | display: block; 188 | font-size: 24px; 189 | } 190 | 191 | 192 | 193 | /** 194 | * Posts 195 | */ 196 | .post-header { 197 | margin-bottom: $spacing-unit; 198 | } 199 | 200 | .post-title { 201 | font-size: 42px; 202 | letter-spacing: -1px; 203 | line-height: 1; 204 | 205 | @include media-query($on-laptop) { 206 | font-size: 36px; 207 | } 208 | } 209 | 210 | .post-content { 211 | margin-bottom: $spacing-unit; 212 | 213 | h2 { 214 | font-size: 32px; 215 | 216 | @include media-query($on-laptop) { 217 | font-size: 28px; 218 | } 219 | } 220 | 221 | h3 { 222 | font-size: 26px; 223 | 224 | @include media-query($on-laptop) { 225 | font-size: 22px; 226 | } 227 | } 228 | 229 | h4 { 230 | font-size: 20px; 231 | 232 | @include media-query($on-laptop) { 233 | font-size: 18px; 234 | } 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /app/assets/javascripts/components/posts.js.cjsx: -------------------------------------------------------------------------------- 1 | # @cjsx React.DOM 2 | 3 | @Posts = React.createClass( 4 | propTypes: 5 | site: React.PropTypes.object.isRequired 6 | 7 | getInitialState: -> 8 | { loading: false, posts: null } 9 | 10 | fetchPosts: -> 11 | return if @state.loading 12 | @setState(loading: true) 13 | $.getq("default", "/api/posts").always(@handleResponse).done(@handleSuccess).fail(@handleError) 14 | 15 | handleResponse: -> 16 | @setState(loading: false) if @isMounted() 17 | 18 | handleSuccess: (response) -> 19 | @setState(posts: response.posts) if @isMounted() 20 | 21 | handleError: (error) -> 22 | $.growl("Could not load posts: #{error.statusText} (#{error.status})", growlError) 23 | 24 | componentWillMount: -> 25 | @fetchPosts() 26 | @timer = setInterval(@fetchPosts, 5000) 27 | $(document).on("fetchPosts", @fetchPosts) 28 | 29 | componentWillUnmount: -> 30 | clearInterval(@timer) 31 | $(document).off("fetchPosts", @fetchPosts) 32 | 33 | render: -> 34 |
35 | 36 | 37 | 38 | {@state.posts?.map(((post) -> 39 | 40 | ).bind(this))} 41 | 42 | 43 | 51 |
52 | ) 53 | 54 | @NewPostPartial = React.createClass( 55 | mixins: [ReactRouter.Navigation] 56 | 57 | propTypes: 58 | site: React.PropTypes.object.isRequired 59 | 60 | getInitialState: -> 61 | { loading: false } 62 | 63 | form: -> 64 | $(@refs.form.getDOMNode()) 65 | 66 | handleSubmit: (event) -> 67 | event.preventDefault() 68 | return if @state.loading 69 | 70 | @setState(loading: true) 71 | $.postq("default", "/api/posts", @form().serialize()).always(@handleResponse).done(@handleSuccess).fail(@handleError) 72 | 73 | handleResponse: -> 74 | @setState(loading: false) 75 | 76 | handleSuccess: (response) -> 77 | @form()[0].reset() 78 | $(document).trigger("fetchPosts") 79 | @transitionTo("post_edit", post_id: response.posts.identifier) 80 | 81 | handleError: (error) -> 82 | $.growl(error.responseJSON?.errors.join(", ") || error.statusText, growlError) 83 | 84 | render: -> 85 |
86 |
87 |
88 |
89 |
90 | 91 |
92 |
93 | 94 |
95 |
96 |
97 |
98 |
99 | ) 100 | 101 | @PostPartial = React.createClass( 102 | mixins: [ReactRouter.Navigation] 103 | 104 | propTypes: 105 | site: React.PropTypes.object.isRequired 106 | post: React.PropTypes.object.isRequired 107 | 108 | getInitialState: -> 109 | { loading: false } 110 | 111 | panelClass: -> 112 | if @props.post.added 113 | "success" 114 | else if @props.post.changed 115 | "warning" 116 | else if @props.post.deleted 117 | "danger" 118 | else 119 | "default" 120 | 121 | isoDate: -> 122 | try 123 | (new Date(@props.post.date.replace(/-/g, "/"))).toISOString() 124 | catch e 125 | null 126 | 127 | handleEdit: -> 128 | @transitionTo("post_edit", post_id: @props.post.identifier) 129 | 130 | handleRevert: -> 131 | return if @state.loading 132 | @setState(loading: true) 133 | 134 | $.ajaxq("default", type: "PATCH", url: "/api/posts/#{@props.post.identifier}/revert"). 135 | always(@handleResponse). 136 | done(@handleSuccess). 137 | fail(@handleError) 138 | 139 | handleDelete: -> 140 | if @props.post.added 141 | return unless confirm("Are you sure? This can't be undone") 142 | 143 | return if @state.loading 144 | @setState(loading: true) 145 | 146 | $.ajaxq("default", type: "DELETE", url: "/api/posts/#{@props.post.identifier}"). 147 | always(@handleResponse). 148 | done(@handleSuccess). 149 | fail(@handleError) 150 | 151 | handleRestore: -> 152 | return if @state.loading 153 | @setState(loading: true) 154 | 155 | $.ajaxq("default", type: "PATCH", url: "/api/posts/#{@props.post.identifier}/restore"). 156 | always(@handleResponse). 157 | done(@handleSuccess). 158 | fail(@handleError) 159 | 160 | handleResponse: -> 161 | @setState(loading: false) 162 | 163 | handleSuccess: (response) -> 164 | $(document).trigger("fetchPosts") 165 | 166 | handleError: (error) -> 167 | $.growl("Could not load post: #{error.statusText} (#{error.status})", growlError) 168 | 169 | render: -> 170 |
171 |
172 |

{@props.post.title}

173 |
174 | {if @isoDate() then moment(@isoDate()).format("LLL") else ""} 175 |
176 |
177 |
178 |
179 |
180 |
181 | {if @props.post.deleted 182 |
183 | 184 |
185 | else 186 |
187 | 188 | {if @props.post.changed 189 | 190 | } 191 | 192 |
193 | } 194 |
195 |
196 |
197 |
198 | ) 199 | 200 | @PostEdit = React.createClass( 201 | mixins: [ReactRouter.State, ReactRouter.Navigation] 202 | 203 | propTypes: 204 | site: React.PropTypes.object.isRequired 205 | 206 | getInitialState: -> 207 | { loading: false, post: null } 208 | 209 | form: -> 210 | $(@refs.form.getDOMNode()) 211 | 212 | frontMatter: -> 213 | @props.site.octodmin.front_matter 214 | 215 | fetchPost: -> 216 | return if @state.loading 217 | @setState(loading: true) 218 | $.getq("default", "/api/posts/#{@getParams().post_id}").always(@handleResponse).done(@handleSuccess).fail(@handleError) 219 | 220 | handleUpload: (event)-> 221 | form = $("
") 222 | form.find("input").on("change", (-> 223 | event.disableButtons("cmdUpload") 224 | data = new FormData(form[0]) 225 | $.ajaxq("default", 226 | type: "POST" 227 | url: "/api/posts/#{@state.post.identifier}/upload" 228 | data: data 229 | cache: false 230 | contentType: false 231 | processData: false 232 | ).always(-> 233 | event.enableButtons("cmdUpload") 234 | ).done(((result) -> 235 | @handleUploadSuccess(event, result) 236 | ).bind(this)).fail(@handleError) 237 | ).bind(this)) 238 | form.find("input").click() 239 | 240 | handleUploadSuccess: (event, response) -> 241 | event.replaceSelection(response.uploads[0]) 242 | 243 | handleBack: (event) -> 244 | event.preventDefault() 245 | @transitionTo("app") 246 | 247 | handleSubmit: (event) -> 248 | event.preventDefault() 249 | return if @state.loading 250 | @setState(loading: true) 251 | 252 | data = @form().serializeObject() 253 | $.ajaxq("default", type: "PATCH", url: "/api/posts/#{@state.post.identifier}", data: data). 254 | always(@handleResponse). 255 | done(@handleFormSuccess). 256 | fail(@handleError) 257 | 258 | handleResponse: -> 259 | @setState(loading: false) 260 | 261 | handleSuccess: (response) -> 262 | @setState(post: response.posts) 263 | 264 | handleFormSuccess: (response) -> 265 | $.growl("Post is updated", growlSuccess) 266 | @transitionTo("post_edit", post_id: response.posts.identifier) 267 | 268 | handleError: (error) -> 269 | $.growl(error.responseJSON?.errors.join(", ") || error.statusText, growlError) 270 | 271 | componentWillMount: -> 272 | @fetchPost() 273 | 274 | componentDidUpdate: -> 275 | return if !@state.post || !@refs.editor 276 | return if !/markdown$/.test(@state.post.path) && !/md$/.test(@state.post.path) 277 | 278 | $(@refs.editor.getDOMNode()).markdown( 279 | autofocus: false 280 | savable: false 281 | iconlibrary: "fa" 282 | resize: "vertical" 283 | fullscreen: 284 | enable: false 285 | additionalButtons: [ 286 | [{ 287 | name: "customGroup" 288 | data: [{ 289 | name: "cmdUpload" 290 | title: "Upload" 291 | icon: "fa fa-upload" 292 | callback: @handleUpload 293 | }] 294 | }] 295 | ] 296 | ) 297 | 298 | render: -> 299 | 300 | {if @state.post 301 |
302 |
303 |
304 |
305 | 306 |
307 |
308 | 309 |
310 |
311 | 312 | 313 | 314 | {Object.keys(@frontMatter()).map(((key) -> 315 | if key != "title" && key != "date" 316 | config = @frontMatter()[key] 317 | title = key.charAt(0).toUpperCase() + key.slice(1) 318 | 319 |
320 | 321 |
322 | 323 |
324 |
325 | ).bind(this))} 326 | 327 |
328 | 329 | 330 |
331 |
332 |
333 | } 334 |
335 | ) 336 | -------------------------------------------------------------------------------- /spec/api/posts_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | shared_examples_for "existing post" do 4 | it "has post" do 5 | expect(subject["identifier"]).to eql("2015-01-30-test") 6 | expect(subject["added"]).to eql(false) 7 | expect(subject["changed"]).to eql(false) 8 | expect(subject["deleted"]).to eql(false) 9 | expect(subject["title"]).to eql("Test") 10 | expect(subject["slug"]).to eql("test") 11 | expect(subject["path"]).to eql("_posts/2015-01-30-test.markdown") 12 | expect(subject["date"]).to start_with("2015-01-30") 13 | expect(subject["excerpt"]).to eql("

OMG

\n\n") 14 | expect(subject["content"]).to eql("# OMG\n\nThis is a *post*!\n") 15 | expect(subject["custom"]).to eql("data") 16 | end 17 | end 18 | 19 | shared_examples_for "new post" do 20 | it "has post" do 21 | expect(File.exists?("sample/_posts/#{date}-new-one.markdown")).to be_truthy 22 | expect(subject["identifier"]).to eql("#{date}-new-one") 23 | expect(subject["added"]).to eql(true) 24 | expect(subject["changed"]).to eql(false) 25 | expect(subject["deleted"]).to eql(false) 26 | expect(subject["title"]).to eql("New One") 27 | expect(subject["slug"]).to eql("new-one") 28 | expect(subject["path"]).to eql("_posts/#{date}-new-one.markdown") 29 | expect(subject["date"]).to start_with(date) 30 | expect(subject["excerpt"]).to eql("

New post from template

\n\n") 31 | expect(subject["content"]).to eql("New post from **template**\n") 32 | expect(subject["custom"]).to eql("default") 33 | end 34 | end 35 | 36 | shared_examples_for "updated post" do 37 | it "has post" do 38 | expect(File.exists?("sample/_posts/2015-01-30-test.markdown")).to be_truthy 39 | expect(subject["identifier"]).to eql("2015-01-30-test") 40 | expect(subject["added"]).to eql(false) 41 | expect(subject["changed"]).to eql(true) 42 | expect(subject["deleted"]).to eql(false) 43 | expect(subject["title"]).to eql("Test") 44 | expect(subject["slug"]).to eql("test") 45 | expect(subject["path"]).to eql("_posts/2015-01-30-test.markdown") 46 | expect(subject["date"]).to start_with("2015-01-30") 47 | expect(subject["excerpt"]).to eql("

WOW

\n\n") 48 | expect(subject["content"]).to eql("### WOW\n") 49 | expect(subject["custom"]).to eql("new") 50 | expect(subject.has_key?("junk")).to be_falsy 51 | end 52 | end 53 | 54 | shared_examples_for "new updated post" do 55 | it "has post" do 56 | expect(File.exists?("sample/_posts/#{date}-new-one.markdown")).to be_falsy 57 | expect(File.exists?("sample/_posts/#{date}-updated-one.markdown")).to be_truthy 58 | expect(subject["identifier"]).to eql("#{date}-updated-one") 59 | expect(subject["added"]).to eql(true) 60 | expect(subject["changed"]).to eql(false) 61 | expect(subject["deleted"]).to eql(false) 62 | expect(subject["title"]).to eql("Updated One") 63 | expect(subject["slug"]).to eql("updated-one") 64 | expect(subject["path"]).to eql("_posts/#{date}-updated-one.markdown") 65 | expect(subject["date"]).to start_with(date) 66 | expect(subject["excerpt"]).to eql("

WOW

\n\n") 67 | expect(subject["content"]).to eql("### WOW\n") 68 | expect(subject["custom"]).to eql("updated") 69 | expect(subject.has_key?("junk")).to be_falsy 70 | end 71 | end 72 | 73 | 74 | shared_examples_for "deleted post" do 75 | it "has no post" do 76 | expect(File.exists?("sample/_posts/2015-01-29-welcome-to-jekyll.markdown")).to be_truthy 77 | expect(subject["identifier"]).to eql("2015-01-29-welcome-to-jekyll") 78 | expect(subject["added"]).to eql(false) 79 | expect(subject["changed"]).to eql(false) 80 | expect(subject["deleted"]).to eql(true) 81 | expect(subject["title"]).to eql("Welcome to Jekyll!") 82 | end 83 | end 84 | 85 | describe "posts" do 86 | let(:app) { Octodmin::App.new(File.expand_path("../..", __dir__)) } 87 | let(:date) { Date.today.strftime("%Y-%m-%d") } 88 | 89 | describe "index" do 90 | before { get "/api/posts" } 91 | subject { parse_json(last_response.body)["posts"] } 92 | 93 | context "first post" do 94 | subject { parse_json(last_response.body)["posts"].first } 95 | it_behaves_like "existing post" 96 | end 97 | 98 | context "last post" do 99 | specify do 100 | expect(subject.last["identifier"]).to eql("2015-01-29-welcome-to-jekyll") 101 | expect(subject.last["title"]).to eql("Welcome to Jekyll!") 102 | end 103 | end 104 | end 105 | 106 | describe "create" do 107 | context "invalid" do 108 | subject { parse_json(last_response.body)["errors"] } 109 | 110 | context "no title" do 111 | before { post "/api/posts" } 112 | 113 | it "is not ok" do 114 | expect(last_response).to_not be_ok 115 | expect(subject).to eql(["Required param `title` is not specified"]) 116 | end 117 | end 118 | 119 | context "same title" do 120 | before do 121 | post "/api/posts", title: "Yo" 122 | post "/api/posts", title: "Yo" 123 | end 124 | after { File.delete("sample/_posts/#{date}-yo.markdown") } 125 | 126 | it "is not ok" do 127 | expect(last_response).to_not be_ok 128 | expect(subject).to eql(["Post with specified `title` already exists"]) 129 | end 130 | end 131 | end 132 | 133 | context "valid" do 134 | context "regular post" do 135 | before { post "/api/posts", title: "New One" } 136 | after { File.delete("sample/_posts/#{date}-new-one.markdown") } 137 | subject { parse_json(last_response.body)["posts"] } 138 | 139 | context "response" do 140 | it_behaves_like "new post" 141 | end 142 | 143 | context "request" do 144 | before { get "/api/posts/#{date}-new-one" } 145 | it_behaves_like "new post" 146 | end 147 | end 148 | 149 | context "special post" do 150 | before { post "/api/posts", title: "Тестовий" } 151 | after { File.delete("sample/_posts/#{date}-testovyi.markdown") } 152 | subject { parse_json(last_response.body)["posts"] } 153 | 154 | context "response" do 155 | it "has post" do 156 | expect(File.exists?("sample/_posts/#{date}-testovyi.markdown")).to be_truthy 157 | expect(subject["identifier"]).to eql("#{date}-testovyi") 158 | end 159 | end 160 | end 161 | end 162 | end 163 | 164 | describe "show" do 165 | context "invalid" do 166 | subject { parse_json(last_response.body)["errors"] } 167 | 168 | context "no post" do 169 | before { get "/api/posts/omg" } 170 | 171 | it "is not ok" do 172 | expect(last_response).to_not be_ok 173 | expect(subject).to eql(["Could not find post"]) 174 | end 175 | end 176 | end 177 | 178 | context "valid" do 179 | before { get "/api/posts/2015-01-30-test" } 180 | subject { parse_json(last_response.body)["posts"] } 181 | it_behaves_like "existing post" 182 | end 183 | end 184 | 185 | describe "update" do 186 | context "invalid" do 187 | subject { parse_json(last_response.body)["errors"] } 188 | 189 | context "no post" do 190 | before { patch "/api/posts/omg" } 191 | 192 | it "is not ok" do 193 | expect(last_response).to_not be_ok 194 | expect(subject).to eql(["Could not find post"]) 195 | end 196 | end 197 | 198 | context "no params" do 199 | before { patch "/api/posts/2015-01-30-test" } 200 | 201 | it "is not ok" do 202 | expect(last_response).to_not be_ok 203 | expect(subject).to eql(["Required params are not specified"]) 204 | end 205 | end 206 | end 207 | 208 | context "valid" do 209 | context "old post" do 210 | before do 211 | patch "/api/posts/2015-01-30-test", { 212 | layout: "other", 213 | title: "Test", 214 | slug: "test", 215 | date: "2015-01-30 21:10:00", 216 | content: "### WOW", 217 | custom: "new", 218 | junk: "shit", 219 | } 220 | end 221 | after do 222 | git = Git.open(Octodmin::App.dir) 223 | git.checkout("sample/_posts/2015-01-30-test.markdown") 224 | end 225 | subject { parse_json(last_response.body)["posts"] } 226 | 227 | context "response" do 228 | it_behaves_like "updated post" 229 | end 230 | 231 | context "request" do 232 | before { get "/api/posts/2015-01-30-test" } 233 | it_behaves_like "updated post" 234 | end 235 | end 236 | 237 | context "new post" do 238 | before do 239 | post "/api/posts", title: "New One" 240 | patch "/api/posts/#{date}-new-one", { 241 | layout: "post", 242 | title: "Updated One", 243 | slug: "updated-one", 244 | date: "#{date} 00:00:00", 245 | content: "### WOW", 246 | custom: "updated", 247 | junk: "shit", 248 | } 249 | end 250 | after do 251 | File.delete("sample/_posts/#{date}-updated-one.markdown") 252 | end 253 | subject { parse_json(last_response.body)["posts"] } 254 | 255 | context "response" do 256 | it_behaves_like "new updated post" 257 | end 258 | 259 | context "request" do 260 | before { get "/api/posts/#{date}-updated-one" } 261 | it_behaves_like "new updated post" 262 | end 263 | end 264 | 265 | context "special post" do 266 | before do 267 | post "/api/posts", title: "New One" 268 | patch "/api/posts/#{date}-new-one", { 269 | layout: "post", 270 | title: "Новий", 271 | slug: "novyi", 272 | date: "#{date} 00:00:00", 273 | content: "### WOW", 274 | custom: "updated", 275 | junk: "shit", 276 | } 277 | end 278 | after do 279 | File.delete("sample/_posts/#{date}-novyi.markdown") 280 | end 281 | subject { parse_json(last_response.body)["posts"] } 282 | 283 | context "response" do 284 | it "has post" do 285 | expect(File.exists?("sample/_posts/#{date}-novyi.markdown")).to be_truthy 286 | expect(subject["identifier"]).to eql("#{date}-novyi") 287 | end 288 | end 289 | end 290 | end 291 | end 292 | 293 | describe "delete" do 294 | context "invalid" do 295 | subject { parse_json(last_response.body)["errors"] } 296 | 297 | context "no post" do 298 | before { delete "/api/posts/omg" } 299 | 300 | it "is not ok" do 301 | expect(last_response).to_not be_ok 302 | expect(subject).to eql(["Could not find post"]) 303 | end 304 | end 305 | end 306 | 307 | context "valid" do 308 | context "new post" do 309 | before do 310 | post "/api/posts", title: "New One" 311 | delete "/api/posts/#{date}-new-one" 312 | get "/api/posts/#{date}-new-one" 313 | end 314 | subject { parse_json(last_response.body)["errors"] } 315 | 316 | it "has no post" do 317 | expect(last_response).to_not be_ok 318 | expect(subject).to eql(["Could not find post"]) 319 | end 320 | end 321 | 322 | context "old post" do 323 | before do 324 | delete "/api/posts/2015-01-29-welcome-to-jekyll" 325 | end 326 | after do 327 | git = Git.open(Octodmin::App.dir) 328 | git.add("sample/_posts/2015-01-29-welcome-to-jekyll.markdown") 329 | end 330 | subject { parse_json(last_response.body)["posts"] } 331 | it_behaves_like "deleted post" 332 | end 333 | end 334 | end 335 | 336 | describe "restore" do 337 | context "invalid" do 338 | subject { parse_json(last_response.body)["errors"] } 339 | 340 | context "no post" do 341 | before { patch "/api/posts/omg/restore" } 342 | 343 | it "is not ok" do 344 | expect(last_response).to_not be_ok 345 | expect(subject).to eql(["Could not find post"]) 346 | end 347 | end 348 | end 349 | 350 | context "valid" do 351 | context "existing post" do 352 | before do 353 | patch "/api/posts/2015-01-30-test/restore" 354 | end 355 | subject { parse_json(last_response.body)["posts"] } 356 | it_behaves_like "existing post" 357 | end 358 | 359 | context "deleted post" do 360 | before do 361 | delete "/api/posts/2015-01-30-test" 362 | patch "/api/posts/2015-01-30-test/restore" 363 | end 364 | subject { parse_json(last_response.body)["posts"] } 365 | it_behaves_like "existing post" 366 | end 367 | end 368 | end 369 | 370 | describe "revert" do 371 | context "invalid" do 372 | subject { parse_json(last_response.body)["errors"] } 373 | 374 | context "no post" do 375 | before { patch "/api/posts/omg/revert" } 376 | 377 | it "is not ok" do 378 | expect(last_response).to_not be_ok 379 | expect(subject).to eql(["Could not find post"]) 380 | end 381 | end 382 | end 383 | 384 | context "valid" do 385 | context "existing post" do 386 | before do 387 | patch "/api/posts/2015-01-30-test/revert" 388 | end 389 | subject { parse_json(last_response.body)["posts"] } 390 | it_behaves_like "existing post" 391 | end 392 | 393 | context "changed post" do 394 | before do 395 | patch "/api/posts/2015-01-30-test", { 396 | layout: "other", 397 | title: "Test", 398 | slug: "test", 399 | date: "2015-01-30 21:10:00", 400 | content: "### WOW", 401 | custom: "new", 402 | junk: "shit", 403 | } 404 | patch "/api/posts/2015-01-30-test/revert" 405 | end 406 | subject { parse_json(last_response.body)["posts"] } 407 | it_behaves_like "existing post" 408 | end 409 | end 410 | end 411 | 412 | describe "upload" do 413 | context "valid" do 414 | before do 415 | post "/api/posts/2015-01-30-test/upload", { 416 | file: Rack::Test::UploadedFile.new("spec/fixtures/ear.png") 417 | } 418 | end 419 | after do 420 | File.delete("sample/octodmin/2015-01-30-test/ear.png") 421 | end 422 | subject { parse_json(last_response.body)["posts"] } 423 | 424 | specify do 425 | expect(File.exists?("sample/octodmin/2015-01-30-test/ear.png")).to be_truthy 426 | end 427 | end 428 | end 429 | end 430 | --------------------------------------------------------------------------------