robin_cms 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +183 -0
- data/lib/jekyll/commands/serve.rb +35 -0
- data/lib/robin_cms/auth.rb +39 -0
- data/lib/robin_cms/cms.rb +200 -0
- data/lib/robin_cms/collection_item.rb +82 -0
- data/lib/robin_cms/configuration-schema.json +116 -0
- data/lib/robin_cms/configuration.rb +181 -0
- data/lib/robin_cms/data_item.rb +88 -0
- data/lib/robin_cms/flash.rb +24 -0
- data/lib/robin_cms/helpers.rb +60 -0
- data/lib/robin_cms/item.rb +34 -0
- data/lib/robin_cms/itemable.rb +124 -0
- data/lib/robin_cms/queryable.rb +47 -0
- data/lib/robin_cms/sluggable.rb +22 -0
- data/lib/robin_cms/static_item.rb +92 -0
- data/lib/robin_cms/version.rb +5 -0
- data/lib/robin_cms/views/change_password.erb +22 -0
- data/lib/robin_cms/views/delete_dialog.erb +25 -0
- data/lib/robin_cms/views/error.erb +1 -0
- data/lib/robin_cms/views/filter_form.erb +37 -0
- data/lib/robin_cms/views/flash.erb +6 -0
- data/lib/robin_cms/views/hidden_field.erb +6 -0
- data/lib/robin_cms/views/image_field.erb +20 -0
- data/lib/robin_cms/views/input_field.erb +9 -0
- data/lib/robin_cms/views/layout.erb +59 -0
- data/lib/robin_cms/views/library.erb +70 -0
- data/lib/robin_cms/views/library_actions.erb +4 -0
- data/lib/robin_cms/views/library_item.erb +44 -0
- data/lib/robin_cms/views/login.erb +17 -0
- data/lib/robin_cms/views/logo.erb +82 -0
- data/lib/robin_cms/views/nav.erb +11 -0
- data/lib/robin_cms/views/new_tab.erb +4 -0
- data/lib/robin_cms/views/profile.erb +12 -0
- data/lib/robin_cms/views/richtext_field.erb +10 -0
- data/lib/robin_cms/views/select_field.erb +23 -0
- data/lib/robin_cms/views/style.erb +441 -0
- data/lib/robin_cms.rb +45 -0
- metadata +139 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 4fbf894e92d463a649deb5a35f5e7dbe6053c2d02b582c2a286ebed0c66b22da
|
4
|
+
data.tar.gz: c4e5ee5768732cedc4c0789083df8e03144a4dcd70b7d68c520547dec9d36a08
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: ba7ce3b355e910b330279514917ce33de24372c4659dac5706c54a3cfc92d2d1d87436818b098bf8f027eb9ae3fcb64796568f7076eef936878d1251754aaff1
|
7
|
+
data.tar.gz: ddfc74c1b20eae5327a37fac66ae66a1a6d1027b26a77900dad1f44b42eb445b1cf617c250dc0f302d8c531b6101f80d5e74bfb5da2d7ee62f51aee02b7683f3
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2024 Aron Lebani
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,183 @@
|
|
1
|
+
# Robin CMS
|
2
|
+
|
3
|
+

|
4
|
+
|
5
|
+
Robin CMS is a minimalist flat-file CMS built with Ruby and Sinatra. It is
|
6
|
+
designed to be used by developers for creating custom built websites where the
|
7
|
+
client needs to be able to update content themselves. It works with any Static
|
8
|
+
Site Generator and can also be embedded in a dynamic Sinatra app. The idea is
|
9
|
+
that you can just drop it into your project and it gives you a completely
|
10
|
+
customised CMS for your website.
|
11
|
+
|
12
|
+
It is completely headless - it gives clients an admin interface where they can
|
13
|
+
manage raw content, while giving the developer full control over the HTML and
|
14
|
+
CSS. That way clients can't accidentally break layouts and design.
|
15
|
+
|
16
|
+
You can define the content model of your website using a YAML file. That way
|
17
|
+
you don't have to wrangle all your data into a "blog" post. You can choose to
|
18
|
+
store content either as HTML (predominantly for content with rich text), or
|
19
|
+
YAML for structured key-value data.
|
20
|
+
|
21
|
+
Robin CMS is designed to keep things as simple as possible. It uses files to
|
22
|
+
store data so you don't have to worry about managing a database. The entire CMS
|
23
|
+
can be installed with just two files - a two line `config.ru` file and a
|
24
|
+
`_cms.yml` configuration file.
|
25
|
+
|
26
|
+
## Motivation
|
27
|
+
|
28
|
+
I know, I know. Another CMS. Whilst there seems to be a plethora of options in
|
29
|
+
PHP and JavaScript, there aren't many options for Ruby. Most of them are big
|
30
|
+
Rails monoliths, designed either to be a full end-to-end CMS like Wordpress, or
|
31
|
+
for the specific use-case of building a blog. I couldn't find a simple
|
32
|
+
headless, flat-file CMS built in Ruby that met my needs. So here we are.
|
33
|
+
|
34
|
+
## Features
|
35
|
+
|
36
|
+
* Headless, flat file CMS
|
37
|
+
* Define custom content model using a YAML configuration file
|
38
|
+
* Store content as files in HTML or YAML format
|
39
|
+
* Works with any Static Site Generator or with a dynamic Sinatra app
|
40
|
+
* Simple to install into your website - you just need to add a `config.ru` and
|
41
|
+
a `_cms.yml` file
|
42
|
+
* Self-contained - you don't need to ship any assets for the CMS admin site
|
43
|
+
|
44
|
+
## Limitations
|
45
|
+
|
46
|
+
In order to keep the CMS simple to maintain for your website, certain features
|
47
|
+
that are commonly found in CMS software have been omitted.
|
48
|
+
|
49
|
+
* Only supports a single user, which means you don't need to maintain a user
|
50
|
+
database.
|
51
|
+
* Does not support relations between content models. This is the nature of a
|
52
|
+
flat-file CMS. If you need data model relations, you are probably better off
|
53
|
+
using an SQL database backend rather than a flat-file backend.
|
54
|
+
* Doesn't support multiple sites. It is designed to be simple enough that you
|
55
|
+
can drop in a separate instance of the CMS for each website.
|
56
|
+
* Does not have a WYSIWYG editor. It is designed purely for managing structured
|
57
|
+
content. You can however add richtext fields to your content models (see
|
58
|
+
example below).
|
59
|
+
|
60
|
+
## Installation
|
61
|
+
|
62
|
+
Just the usual incantation:
|
63
|
+
|
64
|
+
```sh
|
65
|
+
gem install robin_cms
|
66
|
+
```
|
67
|
+
|
68
|
+
## Usage
|
69
|
+
|
70
|
+
You have a few options for using this gem in your project. Firstly, you can use
|
71
|
+
it directly as a CMS for any Static Site Generator with the following
|
72
|
+
`config.ru`:
|
73
|
+
|
74
|
+
```ruby
|
75
|
+
require 'robin_cms'
|
76
|
+
map "/admin" { run RobinCMS::CMS.new }
|
77
|
+
```
|
78
|
+
|
79
|
+
Now you should be able to run `rackup`, and go to `http://localhost:9292/admin`
|
80
|
+
in your browser.
|
81
|
+
|
82
|
+
Alternatively, you can embed it into your own Sinatra project like this:
|
83
|
+
|
84
|
+
```ruby
|
85
|
+
require 'robin_cms'
|
86
|
+
require 'sinatra'
|
87
|
+
|
88
|
+
use RobinCMS::CMS
|
89
|
+
|
90
|
+
get '/' do
|
91
|
+
'Hello, world!'
|
92
|
+
end
|
93
|
+
|
94
|
+
run Sinatra::Application.new
|
95
|
+
```
|
96
|
+
|
97
|
+
Yet another option is to use it as a [Jekyll](https://jekyllrb.com/) plugin. To
|
98
|
+
do this, you just need to pop `robin_cms` in the `:jekyll_plugins` group of
|
99
|
+
your `Gemfile` like so:
|
100
|
+
|
101
|
+
```
|
102
|
+
gem "jekyll"
|
103
|
+
|
104
|
+
group :jekyll_plugins do
|
105
|
+
gem "robin_cms"
|
106
|
+
end
|
107
|
+
```
|
108
|
+
|
109
|
+
After running `bundle exec jekyll serve`, the CMS should be available on your
|
110
|
+
website under `/admin`.
|
111
|
+
|
112
|
+
## Configuring
|
113
|
+
|
114
|
+
You can define your content model in a `_cms.yml` file like this:
|
115
|
+
|
116
|
+
```yml
|
117
|
+
libraries:
|
118
|
+
- name: "poem"
|
119
|
+
label: "Poem"
|
120
|
+
location: "poems"
|
121
|
+
filetype: "html"
|
122
|
+
fields:
|
123
|
+
- { label: "Title", name; "title", type: "input" }
|
124
|
+
- { label: "Author", name; "author_name", type: "input" }
|
125
|
+
- { label: "Content", name; "content", type: "richtext" }
|
126
|
+
- name: "book"
|
127
|
+
label: "Book"
|
128
|
+
location: "books"
|
129
|
+
filetype: "yml"
|
130
|
+
fields:
|
131
|
+
- { label: "Title", name; "title", type: "input" }
|
132
|
+
- { label: "Author", name; "author_name", type: "input" }
|
133
|
+
```
|
134
|
+
|
135
|
+
The admin username and password needs to be set in a `.htpasswd` file in the
|
136
|
+
root directory of the project. Obviously make sure you `.gitignore` that file.
|
137
|
+
Also make sure your static site generator is ignoring it because you don't want
|
138
|
+
it in your public directory! Each line of the `.htpasswd` file should follow
|
139
|
+
the format `<username>:<password>`, but note that only a single
|
140
|
+
username/password is supported for now. The password needs to be encrypted with
|
141
|
+
bcrypt. You can do this in Ruby with the `bcrypt` gem:
|
142
|
+
|
143
|
+
```sh
|
144
|
+
ruby -r bcrypt -e "puts BCrypt::Password.create('mypassword')"
|
145
|
+
```
|
146
|
+
|
147
|
+
Another thing to note is that if no `.htpasswd` file is found, it will
|
148
|
+
automatically create one with username "admin" and password "admin". This lets
|
149
|
+
you play around with it locally without configuring a password. So make sure
|
150
|
+
you create a `.htpasswd` file before running it in production!
|
151
|
+
|
152
|
+
You'll also need to expose a `SESSION_SECRET` environment variable. If you
|
153
|
+
don't, it will create one for you, but it creates a new secret each time
|
154
|
+
the server starts, meaning you will have to log in again whenever you restart
|
155
|
+
the server. It is recommended to create one via Ruby's SecureRandom package.
|
156
|
+
|
157
|
+
```sh
|
158
|
+
ruby -r securerandom -e "puts SecureRandom.hex(64)"
|
159
|
+
```
|
160
|
+
|
161
|
+
See the [examples](./examples) folder for a full example. I haven't written any
|
162
|
+
documentation yet, but the example `_cms.yml` file is thoroughly commented to
|
163
|
+
explain each of the fields.
|
164
|
+
|
165
|
+
## Testing
|
166
|
+
|
167
|
+
Unit test are written in RSpec. To run them:
|
168
|
+
|
169
|
+
```
|
170
|
+
rspec
|
171
|
+
```
|
172
|
+
|
173
|
+
## Roadmap
|
174
|
+
|
175
|
+
* [ ] Support markdown content
|
176
|
+
* [ ] Support alternative serialization formats (json, csv, tsv)
|
177
|
+
* [ ] Filter by any "select" field type
|
178
|
+
* [ ] Descriptions at the field level
|
179
|
+
|
180
|
+
## License
|
181
|
+
|
182
|
+
The gem is available as open source under the terms of the [MIT
|
183
|
+
License](https://opensource.org/licenses/MIT).
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Jekyll
|
4
|
+
module Commands
|
5
|
+
class Serve < Command
|
6
|
+
class << self
|
7
|
+
private
|
8
|
+
|
9
|
+
def start_up_webrick(opts, destination)
|
10
|
+
@reload_reactor.start(opts) if opts["livereload"]
|
11
|
+
|
12
|
+
@server = WEBrick::HTTPServer.new(webrick_opts(opts)).tap { |o| o.unmount("") }
|
13
|
+
@server.mount(opts["baseurl"].to_s, Servlet, destination, file_handler_opts)
|
14
|
+
|
15
|
+
robincms_monkey_patch
|
16
|
+
|
17
|
+
Jekyll.logger.info("Server address:", server_address(@server, opts))
|
18
|
+
launch_browser(@server, opts) if opts["open_url"]
|
19
|
+
boot_or_detach(@server, opts)
|
20
|
+
end
|
21
|
+
|
22
|
+
def robincms_monkey_patch
|
23
|
+
Jekyll::External.require_with_graceful_fail("rackup")
|
24
|
+
|
25
|
+
@server.mount(
|
26
|
+
"/admin",
|
27
|
+
Rackup::Handler::WEBrick,
|
28
|
+
RobinCMS::CMS.new(jekyll_plugin: true)
|
29
|
+
)
|
30
|
+
Jekyll.logger.info("RobinCMS mode:", ENV["RACK_ENV"] || "production")
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RobinCMS
|
4
|
+
module Auth
|
5
|
+
PASSWD_FILE = ".htpasswd"
|
6
|
+
|
7
|
+
def read_credentials
|
8
|
+
if !File.exist?(PASSWD_FILE)
|
9
|
+
username = "admin"
|
10
|
+
password = BCrypt::Password.create("admin")
|
11
|
+
File.write(PASSWD_FILE, "#{username}:#{password}")
|
12
|
+
|
13
|
+
[username, password]
|
14
|
+
else
|
15
|
+
htpasswd = File.readlines(PASSWD_FILE, chomp: true)
|
16
|
+
|
17
|
+
# Only support a single username/password for now
|
18
|
+
htpasswd.first.split(":")
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def update_credentials(password)
|
23
|
+
username, _ = read_credentials
|
24
|
+
hash = BCrypt::Password.create(password)
|
25
|
+
|
26
|
+
File.write(PASSWD_FILE, "#{username}:#{hash}")
|
27
|
+
end
|
28
|
+
|
29
|
+
def authenticate!(username_guess, password_guess)
|
30
|
+
username, password = read_credentials
|
31
|
+
password_ok = BCrypt::Password.new(password) == password_guess
|
32
|
+
username_ok = username == username_guess
|
33
|
+
|
34
|
+
unless username_ok && password_ok
|
35
|
+
raise AuthenticationError, "Incorrect username or password"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,200 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RobinCMS
|
4
|
+
class CMS < Sinatra::Base
|
5
|
+
set :logging, true
|
6
|
+
set :sessions, true
|
7
|
+
set :session_secret, ENV.fetch("SESSION_SECRET", SecureRandom.hex(64))
|
8
|
+
|
9
|
+
attr_accessor :config
|
10
|
+
|
11
|
+
def initialize(app = nil, **opts)
|
12
|
+
super(app)
|
13
|
+
|
14
|
+
@config = Configuration.parse(**opts)
|
15
|
+
end
|
16
|
+
|
17
|
+
helpers do
|
18
|
+
include Helpers
|
19
|
+
include Auth
|
20
|
+
end
|
21
|
+
|
22
|
+
before do
|
23
|
+
if %w[/login /logout].include?(request.path_info)
|
24
|
+
pass
|
25
|
+
end
|
26
|
+
|
27
|
+
redirect to("/login") unless session[:auth_user]
|
28
|
+
end
|
29
|
+
|
30
|
+
before(/\/libraries\/(.*).*/) do
|
31
|
+
kind = params[:captures].first.split("/").first
|
32
|
+
|
33
|
+
@library = @config.find_library(kind)
|
34
|
+
@sorted_fields = @config.sorted_fields(kind)
|
35
|
+
|
36
|
+
halt 404 unless @library
|
37
|
+
end
|
38
|
+
|
39
|
+
after do
|
40
|
+
session[:flash] = @flash.next if @flash
|
41
|
+
end
|
42
|
+
|
43
|
+
get "/" do
|
44
|
+
redirect to(home_page)
|
45
|
+
end
|
46
|
+
|
47
|
+
get "/login" do
|
48
|
+
erb :login
|
49
|
+
end
|
50
|
+
|
51
|
+
post "/login" do
|
52
|
+
authenticate!(params[:username], params[:password])
|
53
|
+
|
54
|
+
session[:auth_user] = params[:username]
|
55
|
+
redirect to(home_page)
|
56
|
+
rescue AuthenticationError
|
57
|
+
flash[:error] = "Incorrect username or password"
|
58
|
+
redirect to("/login")
|
59
|
+
end
|
60
|
+
|
61
|
+
get "/logout" do
|
62
|
+
session[:auth_user] = nil
|
63
|
+
redirect to("/login")
|
64
|
+
end
|
65
|
+
|
66
|
+
get "/profile" do
|
67
|
+
@username = session[:auth_user]
|
68
|
+
|
69
|
+
erb :profile
|
70
|
+
end
|
71
|
+
|
72
|
+
get "/change-password" do
|
73
|
+
erb :change_password
|
74
|
+
end
|
75
|
+
|
76
|
+
post "/change-password" do
|
77
|
+
if params[:confirm_password] != params[:new_password]
|
78
|
+
flash[:error] = "Passwords must match"
|
79
|
+
redirect to("/change-password")
|
80
|
+
end
|
81
|
+
|
82
|
+
authenticate!(session[:auth_user], params[:old_password])
|
83
|
+
|
84
|
+
update_credentials(params[:new_password])
|
85
|
+
session[:auth_user] = nil
|
86
|
+
flash[:success] = "Password updated succesfully. Please log in with your new credentials."
|
87
|
+
|
88
|
+
redirect to("/login")
|
89
|
+
rescue AuthenticationError
|
90
|
+
flash[:error] = "Incorrect username or password"
|
91
|
+
redirect to("/change-password")
|
92
|
+
end
|
93
|
+
|
94
|
+
get "/libraries/:kind" do
|
95
|
+
@items = Item.where(@library,
|
96
|
+
sort: params[:sort],
|
97
|
+
published: params[:published],
|
98
|
+
q: params[:q])
|
99
|
+
|
100
|
+
erb :library
|
101
|
+
end
|
102
|
+
|
103
|
+
get "/libraries/:kind/item" do
|
104
|
+
if params[:id]
|
105
|
+
@item = Item.find_one(@library, params[:id])
|
106
|
+
halt 404 unless @item
|
107
|
+
else
|
108
|
+
@item = Item.blank(@library)
|
109
|
+
end
|
110
|
+
|
111
|
+
erb :library_item
|
112
|
+
end
|
113
|
+
|
114
|
+
post "/libraries/:kind/item" do
|
115
|
+
if params[:id]
|
116
|
+
@item = Item.find_one(@library, params[:id])
|
117
|
+
halt 404 unless @item
|
118
|
+
|
119
|
+
if params[:image]
|
120
|
+
filename = params[:image][:filename]
|
121
|
+
tempfile = params[:image][:tempfile]
|
122
|
+
|
123
|
+
StaticItem.delete_if_exists!(@library, @item.attributes[:image_src])
|
124
|
+
StaticItem.create!(@library, filename, tempfile).then do |s|
|
125
|
+
params[:image_src] = "/" + s.filepath
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
@item.attributes = params
|
130
|
+
@item.update!
|
131
|
+
else
|
132
|
+
if params[:image]
|
133
|
+
filename = params[:image][:filename]
|
134
|
+
tempfile = params[:image][:tempfile]
|
135
|
+
|
136
|
+
StaticItem.create!(@library, filename, tempfile).then do |s|
|
137
|
+
params[:image_src] = "/" + s.filepath
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
Item.create!(@library, params)
|
142
|
+
end
|
143
|
+
|
144
|
+
redirect to("/libraries/#{params[:kind]}")
|
145
|
+
rescue ItemExistsError
|
146
|
+
flash[:error] = "An item with the same name already exists"
|
147
|
+
redirect to("/libraries/#{params[:kind]}/item")
|
148
|
+
end
|
149
|
+
|
150
|
+
post "/libraries/:kind/item/delete" do
|
151
|
+
if params[:id]
|
152
|
+
@item = Item.find_one(@library, params[:id])
|
153
|
+
halt 404 unless @item
|
154
|
+
|
155
|
+
if @item.attributes[:image_src]
|
156
|
+
StaticItem.delete_if_exists!(@library, @item.attributes[:image_src])
|
157
|
+
end
|
158
|
+
|
159
|
+
@item.delete!
|
160
|
+
else
|
161
|
+
halt 404
|
162
|
+
end
|
163
|
+
|
164
|
+
redirect to("/libraries/#{params[:kind]}")
|
165
|
+
end
|
166
|
+
|
167
|
+
post "/publish" do
|
168
|
+
if system(@config[:build_command])
|
169
|
+
flash[:success_dialog] = "Your site has been successfully published!"
|
170
|
+
else
|
171
|
+
flash[:errors] = "There was an error publishing the site"
|
172
|
+
end
|
173
|
+
|
174
|
+
redirect to(home_page)
|
175
|
+
end
|
176
|
+
|
177
|
+
not_found do
|
178
|
+
# This is a hack for showing previews of images uploaded in libraries.
|
179
|
+
# It is needed because until you publish the site, the image is not
|
180
|
+
# copied to the site output, and therefore requests for the file will
|
181
|
+
# return a 404. Note that if using it as a Jekyll plugin, this is not
|
182
|
+
# needed as the files will be served by Jekyll's dev server.
|
183
|
+
|
184
|
+
# Need to use `PATH_INFO` for the same reason we need to use the `url`
|
185
|
+
# and `to` helpers - we want the request URI relative to the path that
|
186
|
+
# the app is mounted at i.e. the virtual location.
|
187
|
+
uri = env["PATH_INFO"]
|
188
|
+
|
189
|
+
library = @config[:libraries].find do |c|
|
190
|
+
uri.start_with?("/#{c[:static_location]}")
|
191
|
+
end
|
192
|
+
|
193
|
+
if library
|
194
|
+
send_file(File.join(library[:static_location], File.basename(uri)))
|
195
|
+
else
|
196
|
+
pass
|
197
|
+
end
|
198
|
+
end
|
199
|
+
end
|
200
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RobinCMS
|
4
|
+
class CollectionItem
|
5
|
+
include Itemable
|
6
|
+
extend Queryable
|
7
|
+
extend Sluggable
|
8
|
+
|
9
|
+
def self.blank(library)
|
10
|
+
new(nil, library)
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.create!(library, attributes)
|
14
|
+
id = make_slug(attributes[:title], library[:pattern])
|
15
|
+
new(id, library, attributes).tap do |item|
|
16
|
+
item.save!
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.find_one(library, id)
|
21
|
+
location = library[:location]
|
22
|
+
ext = library[:filetype]
|
23
|
+
path = File.join(location, "#{id}.#{ext}")
|
24
|
+
|
25
|
+
return unless File.exist?(path)
|
26
|
+
|
27
|
+
deserialize(library, path)
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.all(library)
|
31
|
+
location = library[:location]
|
32
|
+
ext = library[:filetype]
|
33
|
+
path = File.join(location, "*.#{ext}")
|
34
|
+
|
35
|
+
Dir.glob(path).filter { |f| File.file?(f) }.map do |path|
|
36
|
+
deserialize(library, path)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def self.deserialize(library, path)
|
41
|
+
raw = File.read(path)
|
42
|
+
id = File.basename(path, File.extname(path))
|
43
|
+
|
44
|
+
_, frontmatter, content = raw.split("---")
|
45
|
+
|
46
|
+
attributes = YAML.load(frontmatter, symbolize_names: true)
|
47
|
+
attributes[:content] = content.strip
|
48
|
+
|
49
|
+
new(id, library, attributes)
|
50
|
+
end
|
51
|
+
|
52
|
+
private_class_method :deserialize
|
53
|
+
|
54
|
+
def filepath
|
55
|
+
location = @library[:location]
|
56
|
+
name = @id
|
57
|
+
ext = @library[:filetype]
|
58
|
+
File.join(location, "#{name}.#{ext}")
|
59
|
+
end
|
60
|
+
|
61
|
+
def save!
|
62
|
+
if File.exist?(filepath)
|
63
|
+
raise ItemExistsError, "An item with the same name already exists"
|
64
|
+
end
|
65
|
+
|
66
|
+
super
|
67
|
+
end
|
68
|
+
|
69
|
+
private
|
70
|
+
|
71
|
+
def serialize
|
72
|
+
# The Psych module (for which YAML is an alias) has a
|
73
|
+
# stringify_names option which does exactly this. However it was
|
74
|
+
# only introduced in Ruby 3.4. Using transform_keys is a workaround
|
75
|
+
# to support earlier versions of Ruby.
|
76
|
+
fm = frontmatter.to_h.transform_keys(&:to_s)
|
77
|
+
|
78
|
+
content = @attributes[:content]
|
79
|
+
YAML.dump(fm, stringify_names: true) << "---\n" << content
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,116 @@
|
|
1
|
+
{
|
2
|
+
"$schema": "http://json-schema.org/draft-06/schema#",
|
3
|
+
"title": "RobinCMS configuration schema",
|
4
|
+
"type": "object",
|
5
|
+
"required": ["url", "libraries"],
|
6
|
+
"properties": {
|
7
|
+
"title": { "type": "string" },
|
8
|
+
"url": { "type": "string" },
|
9
|
+
"build_command": { "type": "string" },
|
10
|
+
"accent_color": {
|
11
|
+
"type": "string",
|
12
|
+
"pattern": "^\#{1}[a-fA-F0-9]{6}"
|
13
|
+
},
|
14
|
+
"libraries": {
|
15
|
+
"type": "array",
|
16
|
+
"items": {
|
17
|
+
"anyOf": [
|
18
|
+
{ "$ref": "#/$defs/library_schema" },
|
19
|
+
{ "$ref": "#/$defs/data_schema" }
|
20
|
+
]
|
21
|
+
}
|
22
|
+
}
|
23
|
+
},
|
24
|
+
"$defs": {
|
25
|
+
"options_schema": {
|
26
|
+
"type": "array",
|
27
|
+
"items": {
|
28
|
+
"type": "object",
|
29
|
+
"required": ["label", "value"],
|
30
|
+
"properties": {
|
31
|
+
"label": { "type": "string" },
|
32
|
+
"value": { "type": "string" }
|
33
|
+
}
|
34
|
+
}
|
35
|
+
},
|
36
|
+
"image_dimensions_schema": { "type": "string", "pattern": "^[0-9]+x[0-9]+$" },
|
37
|
+
"image_filetype_schema": { "type": "string", "enum": ["png", "jpg"] },
|
38
|
+
"library_schema": {
|
39
|
+
"type": "object",
|
40
|
+
"required": ["id", "label", "label_singular"],
|
41
|
+
"properties": {
|
42
|
+
"id": { "type": "string" },
|
43
|
+
"type": { "type": "string", "const": "collection" },
|
44
|
+
"filetype": { "type": "string", "enum": ["html"] },
|
45
|
+
"label": { "type": "string" },
|
46
|
+
"label_singular": { "type": "string" },
|
47
|
+
"location": { "type": "string" },
|
48
|
+
"static_location": { "type": "string" },
|
49
|
+
"description": { "type": "string" },
|
50
|
+
"can_create": { "type": "boolean" },
|
51
|
+
"can_delete": { "type": "boolean" },
|
52
|
+
"pattern": { "type": "string" },
|
53
|
+
"fields": {
|
54
|
+
"type": "array",
|
55
|
+
"items": {
|
56
|
+
"type": "object",
|
57
|
+
"required": ["id", "label"],
|
58
|
+
"properties": {
|
59
|
+
"id": { "type": "string" },
|
60
|
+
"label": { "type": "string" },
|
61
|
+
"type": {
|
62
|
+
"type": "string",
|
63
|
+
"enum": ["text", "richtext", "date", "hidden", "number", "color", "email", "url", "select", "image"]
|
64
|
+
},
|
65
|
+
"default": { "type": "string" },
|
66
|
+
"required": { "type": "boolean" },
|
67
|
+
"readonly": { "type": "boolean" },
|
68
|
+
"options": { "$ref": "#/$defs/options_schema" },
|
69
|
+
"dimensions": { "$ref": "#/$defs/image_dimensions_schema" },
|
70
|
+
"filetype": { "$ref": "#/$defs/image_filetype_schema" },
|
71
|
+
"order": { "type": "number" }
|
72
|
+
}
|
73
|
+
}
|
74
|
+
}
|
75
|
+
}
|
76
|
+
},
|
77
|
+
"data_schema": {
|
78
|
+
"type": "object",
|
79
|
+
"required": ["id", "label", "label_singular"],
|
80
|
+
"properties": {
|
81
|
+
"id": { "type": "string" },
|
82
|
+
"type": { "type": "string", "const": "data" },
|
83
|
+
"filetype": { "type": "string", "enum": ["yml"] },
|
84
|
+
"label": { "type": "string" },
|
85
|
+
"label_singular": { "type": "string" },
|
86
|
+
"location": { "type": "string" },
|
87
|
+
"static_location": { "type": "string" },
|
88
|
+
"description": { "type": "string" },
|
89
|
+
"can_create": { "type": "boolean" },
|
90
|
+
"can_delete": { "type": "boolean" },
|
91
|
+
"pattern": { "type": "string" },
|
92
|
+
"fields": {
|
93
|
+
"type": "array",
|
94
|
+
"items": {
|
95
|
+
"type": "object",
|
96
|
+
"required": ["id", "label"],
|
97
|
+
"properties": {
|
98
|
+
"id": { "type": "string" },
|
99
|
+
"label": { "type": "string" },
|
100
|
+
"type": {
|
101
|
+
"type": "string",
|
102
|
+
"enum": ["text", "date", "hidden", "number", "color", "email", "url", "select", "image"]
|
103
|
+
},
|
104
|
+
"default": { "type": "string" },
|
105
|
+
"required": { "type": "boolean" },
|
106
|
+
"readonly": { "type": "boolean" },
|
107
|
+
"options": { "$ref": "#/$defs/options_schema" },
|
108
|
+
"dimensions": { "$ref": "#/$defs/image_dimensions_schema" },
|
109
|
+
"filetype": { "$ref": "#/$defs/image_filetype_schema" }
|
110
|
+
}
|
111
|
+
}
|
112
|
+
}
|
113
|
+
}
|
114
|
+
}
|
115
|
+
}
|
116
|
+
}
|