diff --git a/Gemfile b/Gemfile index 85d6f9e3..8920ca45 100644 --- a/Gemfile +++ b/Gemfile @@ -11,4 +11,5 @@ gem 'sinatra_warden' group :development do gem 'pry' gem 'rubocop' + gem 'sinatra-contrib' end diff --git a/Gemfile.lock b/Gemfile.lock index b027091f..c48e255b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -4,6 +4,7 @@ GEM addressable (2.5.2) public_suffix (>= 2.0.2, < 4.0) ast (2.3.0) + backports (3.8.0) coderay (1.1.2) colorator (1.1.0) email_address (0.1.3) @@ -36,6 +37,7 @@ GEM rb-inotify (~> 0.9, >= 0.9.7) mercenary (0.3.6) method_source (0.8.2) + multi_json (1.12.2) mustermann (1.0.1) netaddr (1.5.1) parallel (1.12.0) @@ -80,6 +82,13 @@ GEM rack (~> 2.0) rack-protection (= 2.0.0) tilt (~> 2.0) + sinatra-contrib (2.0.0) + backports (>= 2.0) + multi_json + mustermann (~> 1.0) + rack-protection (= 2.0.0) + sinatra (= 2.0.0) + tilt (>= 1.3, < 3) sinatra_warden (0.3.2) sinatra (>= 1.0.0) warden (~> 1.0) @@ -101,6 +110,7 @@ DEPENDENCIES rubocop sass sinatra + sinatra-contrib sinatra_warden BUNDLED WITH diff --git a/README.md b/README.md index 8a4ad284..15924d9c 100644 --- a/README.md +++ b/README.md @@ -25,3 +25,29 @@ archivo `Gemfile`. ```bash bundle install ``` + +## Ideas + +Sutty actua como una granja de sitios Jekyll. La idea principal es que +una sola instancia de Sutty es capaz de gestionar muchos sitios Jekyll. + +### Sin base de datos + +Sutty es capaz de obtener toda la información necesaria a partir de la +estructura de directorios. + +### Sin autenticación + +Al menos sin autenticación propia. Sutty es capaz de funcionar con +mecanismos de autenticación diversos. Actualmente solo funciona con +LDAP. Podría ni tener autenticación! + +### Sin administradoras ni administradas + +No hay una cuenta de administración que decide a quién corresponde un +sitio o quién puede escribir o no. Las usuarias crean sus propios +sitios e invitan a otras usuarias a co-gestionarlos. + +### Múltiples versiones de Jekyll + +Debe ser capaz de soportar diferentes versiones de Jekyll. diff --git a/lib/jekyll.rb b/lib/jekyll.rb new file mode 100644 index 00000000..98af36af --- /dev/null +++ b/lib/jekyll.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require 'jekyll' + +# Monkeypatcheando Jekyll +module Jekyll + # Métodos agregados o modificados a Site + class Site + # Obtener un nombre para el sitio + def name + @name ||= File.basename(dest) + end + end +end diff --git a/lib/sutty.rb b/lib/sutty.rb index 4d5be5ab..64e3f3ba 100644 --- a/lib/sutty.rb +++ b/lib/sutty.rb @@ -1,11 +1,12 @@ # frozen_string_literal: true require 'yaml' -require_relative 'sutty/models/jekyll' +require 'jekyll' +require_relative 'jekyll' +require_relative 'sutty/models/post' # Sutty module Sutty - # La raíz def self.root @root ||= File.expand_path(File.join(File.dirname(__FILE__), '..')) @@ -13,7 +14,7 @@ module Sutty # La configuración def self.settings - @settings ||= YAML.load(File.read(Sutty.config_file)) + @settings ||= YAML.safe_load(File.read(Sutty.config_file)) end def self.config_file @@ -24,8 +25,37 @@ module Sutty @sites_dir ||= File.join(Sutty.root, Sutty.settings['sites_dir']) end + # Comprueba que el directorio parezca ser de jekyll + def self.jekyll?(dir) + File.directory?(dir) && File.exist?(File.join(dir, '_config.yml')) + end + def self.sites - binding.pry - @sites ||= Jekyll.all + @sites ||= Dir.entries('_sites/').map do |j| + # no queremos . ni .. ni archivos ocultos + next if j.start_with? '.' + + j = Sutty.path_from_name(j) + next unless Sutty.jekyll? j + + Dir.chdir(j) do + config = ::Jekyll.configuration('source' => Dir.pwd) + + # No necesitamos cargar plugins en este momento + %w[plugins gems].each do |unneeded| + config[unneeded] = [] if config.key? unneeded + end + + ::Jekyll::Site.new(config) + end + end.compact + end + + def self.find(name) + Sutty.sites.select { |s| s.name == name }.first + end + + def self.path_from_name(name) + File.realpath(File.join(Sutty.sites_dir, name)) end end diff --git a/lib/sutty/app.rb b/lib/sutty/app.rb index e8c0a590..345047af 100644 --- a/lib/sutty/app.rb +++ b/lib/sutty/app.rb @@ -2,8 +2,10 @@ require 'rack-flash' require 'sinatra/base' +require 'sinatra/reloader' if ENV['RACK_ENV'] == 'development' require 'sinatra_warden' require_relative 'login' +require_relative 'site' require_relative '../sutty' module Sutty @@ -11,12 +13,17 @@ module Sutty class App < Sinatra::Base use Rack::Flash use Sutty::Login + use Sutty::Site register Sinatra::Warden + configure :development do + register Sinatra::Reloader + end + set :root, Sutty.root before do - authorize! '/login' + authorize! '/login' if ENV['RACK_ENV'] == 'production' end get '/' do diff --git a/lib/sutty/models/jekyll.rb b/lib/sutty/models/jekyll.rb deleted file mode 100644 index 3499cc6e..00000000 --- a/lib/sutty/models/jekyll.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -module Sutty - # Un sitio jekyll - class Jekyll - # Devuelve todos los sitios dentro de sites_dir como instancias de - # Jekyll - def self.all - Dir.entries(Sutty.sites_dir).map do |j| - # no queremos . ni .. ni archivos ocultos - next if j.start_with? '.' - - _j = File.realpath(File.join(Sutty.sites_dir, j)) - next unless Jekyll.is_jekyll? _j - - Jekyll.new(_j, j) - end.compact - end - - # Comprueba que el directorio parezca ser de jekyll - def self.is_jekyll?(dir) - File.directory?(dir) && File.exists?(File.join(dir, '_config.yml')) - end - - def initialize(dir, name = nil) - @root = dir - @name = name if name - end - - def config - @config ||= YAML.load(File.read(File.join(@root, '_config.yml'))) - end - - def name - @name ||= File.basename(@root) - end - - end -end diff --git a/lib/sutty/models/post.rb b/lib/sutty/models/post.rb new file mode 100644 index 00000000..afaa246f --- /dev/null +++ b/lib/sutty/models/post.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'yaml' + +module Sutty + module Models + # Un post + class Post + attr_accessor :content, :front_matter + attr_reader :post, :site + + REJECT_FROM_DATA = %w[excerpt slug draft date ext].freeze + + def initialize(site, post = nil) + @site = site + @post = post + end + + def new? + !@post.is_a? Jekyll::Document + end + + def save + front_matter_from_data! + clean_content! + + return unless write.zero? + + @post.read + true + end + + private + + def front_matter_from_data! + @front_matter ||= @post.data.reject do |key, _| + REJECT_FROM_DATA.include? key + end + end + + # Aplica limpiezas básicas del contenido + def clean_content! + @content = @content.delete("\r") + end + + def write + File.open(@post.path, File::RDWR | File::CREAT, 0o640) do |f| + # Bloquear el archivo para que no sea accedido por otro + # proceso u otra editora + f.flock(File::LOCK_EX) + + # Empezar por el principio + f.rewind + + # Escribir + f.write(full_content) + + # Eliminar el resto + f.flush + f.truncate(f.pos) + end + end + + def full_content + "#{@front_matter.to_yaml}---\n\n#{@content}" + end + end + end +end diff --git a/lib/sutty/post.rb b/lib/sutty/post.rb new file mode 100644 index 00000000..1e603236 --- /dev/null +++ b/lib/sutty/post.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'sinatra/base' +require 'sinatra/namespace' +require 'sinatra/reloader' if ENV['RACK_ENV'] == 'development' +require 'sinatra_warden' +require_relative 'login' +require_relative '../sutty' + +module Sutty + # El gestor de posts + class Post < Sinatra::Base + use Sutty::Login + register Sinatra::Warden + register Sinatra::Namespace + + set :root, Sutty.root + + configure :development do + register Sinatra::Reloader + end + + namespace '/sites/:name/posts' do + before do + authorize! '/login' if ENV['RACK_ENV'] == 'production' + @site = Sutty.find(params['name']) + @site.read if @site.posts.docs.empty? + end + + get do + haml :'posts/index' + end + + namespace '/:basename' do + before do + @post = @site.posts.docs.select do |d| + d.basename == params['basename'] + end.first + end + + get do + haml :'posts/show' + end + + get '/edit' do + haml :'posts/edit' + end + + post do + post = Sutty::Models::Post.new(@site, @post) + post.content = params[:post][:content] + + if post.save + flash[:success] = 'Artículo guardado con éxito' + redirect to("/sites/#{@site.name}/posts/#{@post.basename}") + else + flash[:error] = 'Hubo un error al guardar el artículo' + redirect back + end + end + end + end + end +end diff --git a/lib/sutty/site.rb b/lib/sutty/site.rb new file mode 100644 index 00000000..9d09c25b --- /dev/null +++ b/lib/sutty/site.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'sinatra/base' +require 'sinatra/namespace' +require 'sinatra/reloader' if ENV['RACK_ENV'] == 'development' +require 'sinatra_warden' +require_relative 'login' +require_relative 'post' +require_relative '../sutty' + +module Sutty + # El gestor de sitios + class Site < Sinatra::Base + use Sutty::Login + register Sinatra::Warden + register Sinatra::Namespace + + set :root, Sutty.root + set :read, false + + configure :development do + register Sinatra::Reloader + end + + namespace '/sites/:name' do + before do + authorize! '/login' if ENV['RACK_ENV'] == 'production' + @site = Sutty.find(params['name']) + @site.read if @site.posts.docs.empty? + end + + get do + haml :'sites/show' + end + + use Sutty::Post + end + end +end diff --git a/views/index.haml b/views/index.haml index 2a907bb4..9289f740 100644 --- a/views/index.haml +++ b/views/index.haml @@ -5,4 +5,8 @@ %tbody - Sutty.sites.each do |site| %tr - %td= site.name + %td + %a{href: '/sites/' + site.name} + = site.name + %td + %a{href: 'http://' + site.name} visitar diff --git a/views/layout.haml b/views/layout.haml index bcda0350..820927c5 100644 --- a/views/layout.haml +++ b/views/layout.haml @@ -6,7 +6,7 @@ %link{rel: 'stylesheet', type: 'text/css', href: '/assets/css/bootstrap.min.css'} %link{rel: 'stylesheet', type: 'text/css', href: '/stylesheets/sutty.css'} %body{class: @has_cover ? 'background-cover' : ''} - .container + .container-fluid = yield %footer.footer %p{style: @has_cover ? 'color: white' : ''} diff --git a/views/posts/_form.haml b/views/posts/_form.haml new file mode 100644 index 00000000..37f1a42c --- /dev/null +++ b/views/posts/_form.haml @@ -0,0 +1,6 @@ +%form{method: 'post', action: "/sites/#{@site.name}/posts/#{@post.basename}"} + .form-group + %button.btn.btn-success{type: 'submit'} Guardar + .form-group + %textarea.form-control{name: 'post[content]', id: 'contents', autofocus: true} + = @post.content diff --git a/views/posts/edit.haml b/views/posts/edit.haml new file mode 100644 index 00000000..38e4fd30 --- /dev/null +++ b/views/posts/edit.haml @@ -0,0 +1,9 @@ +.row + .col-2 + = haml :'sites/_nav' + .col-10 + %h1 + Editando + = @post.data['title'] + + = haml :'posts/_form' diff --git a/views/posts/index.haml b/views/posts/index.haml new file mode 100644 index 00000000..dfbbeb92 --- /dev/null +++ b/views/posts/index.haml @@ -0,0 +1,13 @@ +.row + .col-2 + = haml :'sites/_nav' + .col-10 + %h1= @site.name + + %table.table.table-striped + %tbody + - @site.posts.docs.each do |post| + %tr + %td + %a{href: "/sites/#{@site.name}/posts/#{post.basename}"}= post.data['title'] + %td= post.date.strftime('%F') diff --git a/views/posts/show.haml b/views/posts/show.haml new file mode 100644 index 00000000..30183748 --- /dev/null +++ b/views/posts/show.haml @@ -0,0 +1,12 @@ +.row + .col-2 + = haml :'sites/_nav' + .col-10 + %a.btn.btn-info{href: "/sites/#{@site.name}/posts/#{@post.basename}/edit"} + Editar + + %h1= @post.data['title'] + + .content + :markdown + #{@post.content} diff --git a/views/sites/_nav.haml b/views/sites/_nav.haml new file mode 100644 index 00000000..859b839d --- /dev/null +++ b/views/sites/_nav.haml @@ -0,0 +1,5 @@ +%ul.nav.flex-column + %li.nav-item + %a.nav-link{href: "/sites/#{@site.name}/posts"} + Posts + %span.badge.badge-secondary= @site.posts.docs.count diff --git a/views/sites/show.haml b/views/sites/show.haml new file mode 100644 index 00000000..ab96c002 --- /dev/null +++ b/views/sites/show.haml @@ -0,0 +1,5 @@ +.row + .col-2 + = haml :'sites/_nav' + .col-10 + %h1= @site.name