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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +183 -0
  4. data/lib/jekyll/commands/serve.rb +35 -0
  5. data/lib/robin_cms/auth.rb +39 -0
  6. data/lib/robin_cms/cms.rb +200 -0
  7. data/lib/robin_cms/collection_item.rb +82 -0
  8. data/lib/robin_cms/configuration-schema.json +116 -0
  9. data/lib/robin_cms/configuration.rb +181 -0
  10. data/lib/robin_cms/data_item.rb +88 -0
  11. data/lib/robin_cms/flash.rb +24 -0
  12. data/lib/robin_cms/helpers.rb +60 -0
  13. data/lib/robin_cms/item.rb +34 -0
  14. data/lib/robin_cms/itemable.rb +124 -0
  15. data/lib/robin_cms/queryable.rb +47 -0
  16. data/lib/robin_cms/sluggable.rb +22 -0
  17. data/lib/robin_cms/static_item.rb +92 -0
  18. data/lib/robin_cms/version.rb +5 -0
  19. data/lib/robin_cms/views/change_password.erb +22 -0
  20. data/lib/robin_cms/views/delete_dialog.erb +25 -0
  21. data/lib/robin_cms/views/error.erb +1 -0
  22. data/lib/robin_cms/views/filter_form.erb +37 -0
  23. data/lib/robin_cms/views/flash.erb +6 -0
  24. data/lib/robin_cms/views/hidden_field.erb +6 -0
  25. data/lib/robin_cms/views/image_field.erb +20 -0
  26. data/lib/robin_cms/views/input_field.erb +9 -0
  27. data/lib/robin_cms/views/layout.erb +59 -0
  28. data/lib/robin_cms/views/library.erb +70 -0
  29. data/lib/robin_cms/views/library_actions.erb +4 -0
  30. data/lib/robin_cms/views/library_item.erb +44 -0
  31. data/lib/robin_cms/views/login.erb +17 -0
  32. data/lib/robin_cms/views/logo.erb +82 -0
  33. data/lib/robin_cms/views/nav.erb +11 -0
  34. data/lib/robin_cms/views/new_tab.erb +4 -0
  35. data/lib/robin_cms/views/profile.erb +12 -0
  36. data/lib/robin_cms/views/richtext_field.erb +10 -0
  37. data/lib/robin_cms/views/select_field.erb +23 -0
  38. data/lib/robin_cms/views/style.erb +441 -0
  39. data/lib/robin_cms.rb +45 -0
  40. 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
+ ![Robin CMS logo](./assets/robin-logo.png)
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
+ }