robin_cms 0.1.1 → 0.1.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 36e4c02115d1c4d3849376ff70e8c20fd0fb1e80c6ae806f4ae09a7a88e981bf
4
- data.tar.gz: f6a86d8721ee1180221c19f7480341accfc3cf5e191f1fde4eab4a7747c757ce
3
+ metadata.gz: 7496d517d8e733bc0e0876e806347a0c3670efc489d974b16718e3b015d0a8e1
4
+ data.tar.gz: cc6df56fde6950e75fddf3fef947e5363cdc67f506bd07c16f5bbe0f1940d629
5
5
  SHA512:
6
- metadata.gz: cefc9e3a84b3b19c786f2110e0baffe2ceafdea25e120320d3a0241ca4aa934775674df7157e0acb9a03dda831d5787516751ec59716ff39436e724e874a696f
7
- data.tar.gz: ca16243899f791e64780e268aea3a45c54e2f4d9e6dfd160b4ba07adaf5d25e79f6d3fd7404f42fd67f0b849725963f17e879db10c0ace46dba641d5e95d5f51
6
+ metadata.gz: 6f4c01cfe9c26b0c7e864af98d405dfe55fbe986f78a3ee9295cb20e9c6bd808bbd9473685ddd17c158574540a63535c16a783a650a937f3e6419f215a81c51f
7
+ data.tar.gz: 36e240374f3bcf0c564d15347b4847a6b781d7b6192b3b3214fbf35f297748415d1729773039dca5f61b00ce088d681d7b3b02ac340fc3aa1f981c2fa4dd63dc
data/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Robin CMS
2
2
 
3
+ > [!IMPORTANT]
4
+ > This software is currently in beta. There may be bugs and breaking changes.
5
+ > If you find a bug, I'd love to hear about it.
6
+
3
7
  ![Robin CMS logo](./assets/robin-logo.png)
4
8
 
5
9
  Robin CMS is a minimalist flat-file CMS built with Ruby and Sinatra. It is
@@ -65,6 +69,63 @@ Just the usual incantation:
65
69
  gem install robin_cms
66
70
  ```
67
71
 
72
+ ## Configuring
73
+
74
+ You can define your content model in a `_cms.yml` file like this:
75
+
76
+ ```yml
77
+ url: https://example.com
78
+ title: Example
79
+ libraries:
80
+ - id: poem
81
+ type: collection
82
+ label: Poem
83
+ location: poems
84
+ filetype: html
85
+ fields:
86
+ - { label: Title, id: title, type: input }
87
+ - { label: Author, id: author_name, type: input }
88
+ - { label: Content, id: content, type: richtext }
89
+ - id: book
90
+ type: data
91
+ label: Book
92
+ location: books
93
+ filetype: yml
94
+ fields:
95
+ - { label: Title, id: title, type: input }
96
+ - { label: Author, id: author_name, type: input }
97
+ ```
98
+
99
+ The admin username and password needs to be set in a `.htpasswd` file in the
100
+ root directory of the project. Obviously make sure you `.gitignore` that file.
101
+ Also make sure your static site generator is ignoring it because you don't want
102
+ it in your public directory! Each line of the `.htpasswd` file should follow
103
+ the format `<username>:<password>`, but note that only a single
104
+ username/password is supported for now. The password needs to be encrypted with
105
+ bcrypt. You can do this in Ruby with the `bcrypt` gem:
106
+
107
+ ```sh
108
+ ruby -r bcrypt -e "puts BCrypt::Password.create('mypassword')"
109
+ ```
110
+
111
+ Another thing to note is that if no `.htpasswd` file is found, it will
112
+ automatically create one with username "admin" and password "admin". This lets
113
+ you play around with it locally without configuring a password. So make sure
114
+ you create a `.htpasswd` file before running it in production!
115
+
116
+ You'll also need to expose a `SESSION_SECRET` environment variable. If you
117
+ don't, it will create one for you, but it creates a new secret each time
118
+ the server starts, meaning you will have to log in again whenever you restart
119
+ the server. It is recommended to create one via Ruby's SecureRandom package.
120
+
121
+ ```sh
122
+ ruby -r securerandom -e "puts SecureRandom.hex(64)"
123
+ ```
124
+
125
+ See the [examples](./examples) folder for a full example. I haven't written any
126
+ documentation yet, but the example `_cms.yml` file is thoroughly commented to
127
+ explain each of the fields.
128
+
68
129
  ## Usage
69
130
 
70
131
  You have a few options for using this gem in your project. Firstly, you can use
@@ -107,60 +168,10 @@ end
107
168
  ```
108
169
 
109
170
  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.
171
+ website under `/admin`. Note that if using it as a Jekyll plugin, you can put
172
+ your config in Jekyll's `_config.yml` file under the `cms` field. You also
173
+ don't need to specify the `url` and `title` fields, as these are taken from the
174
+ Jekyll config.
164
175
 
165
176
  ## Testing
166
177
 
data/lib/robin_cms/cms.rb CHANGED
@@ -2,9 +2,8 @@
2
2
 
3
3
  module RobinCMS
4
4
  class CMS < Sinatra::Base
5
- set :logging, true
6
- set :sessions, true
7
- set :session_secret, ENV.fetch("SESSION_SECRET", SecureRandom.hex(64))
5
+ include Helpers
6
+ include Auth
8
7
 
9
8
  attr_accessor :config
10
9
 
@@ -14,10 +13,9 @@ module RobinCMS
14
13
  @config = Configuration.parse(**opts)
15
14
  end
16
15
 
17
- helpers do
18
- include Helpers
19
- include Auth
20
- end
16
+ set :logging, true
17
+ set :sessions, true
18
+ set :session_secret, ENV.fetch("SESSION_SECRET", SecureRandom.hex(64))
21
19
 
22
20
  before do
23
21
  if %w[/login /logout].include?(request.path_info)
@@ -27,13 +25,18 @@ module RobinCMS
27
25
  redirect to("/login") unless session[:auth_user]
28
26
  end
29
27
 
30
- before(/\/libraries\/(.*).*/) do
28
+ before /\/libraries\/(.*).*/ do
31
29
  kind = params[:captures].first.split("/").first
30
+ library_schema = @config.find_library(kind)
32
31
 
33
- @library = @config.find_library(kind)
34
- @sorted_fields = @config.sorted_fields(kind)
32
+ halt 404 unless library_schema
35
33
 
36
- halt 404 unless @library
34
+ @library = case library_schema[:type]
35
+ when "collection"
36
+ CollectionLibrary.new(library_schema)
37
+ when "data"
38
+ DataLibrary.new(library_schema)
39
+ end
37
40
  end
38
41
 
39
42
  after do
@@ -92,20 +95,21 @@ module RobinCMS
92
95
  end
93
96
 
94
97
  get "/libraries/:kind" do
95
- @items = Item.where(@library,
98
+ @items = @library.query(
96
99
  sort: params[:sort],
97
100
  published: params[:published],
98
- q: params[:q])
101
+ q: params[:q]
102
+ )
99
103
 
100
104
  erb :library
101
105
  end
102
106
 
103
107
  get "/libraries/:kind/item" do
104
108
  if params[:id]
105
- @item = Item.find_one(@library, params[:id])
109
+ @item = @library.find_one(params[:id])
106
110
  halt 404 unless @item
107
111
  else
108
- @item = Item.blank(@library)
112
+ @item = @library.blank
109
113
  end
110
114
 
111
115
  erb :library_item
@@ -113,32 +117,30 @@ module RobinCMS
113
117
 
114
118
  post "/libraries/:kind/item" do
115
119
  if params[:id]
116
- @item = Item.find_one(@library, params[:id])
120
+ @item = @library.find_one(params[:id])
117
121
  halt 404 unless @item
118
122
 
119
123
  if params[:image]
120
124
  filename = params[:image][:filename]
121
- tempfile = params[:image][:tempfile]
125
+ tempfile = params[:image][:tempfile].to_path
122
126
 
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
+ @library.delete_static(@item.attributes[:image_src])
128
+ static_path = @library.create_static(filename, tempfile)
129
+ params[:image_src] = "/" + static_path
127
130
  end
128
131
 
129
132
  @item.attributes = params
130
- @item.update!
133
+ @library.write(@item)
131
134
  else
132
135
  if params[:image]
133
136
  filename = params[:image][:filename]
134
- tempfile = params[:image][:tempfile]
137
+ tempfile = params[:image][:tempfile].to_path
135
138
 
136
- StaticItem.create!(@library, filename, tempfile).then do |s|
137
- params[:image_src] = "/" + s.filepath
138
- end
139
+ static_path = @library.create_static(filename, tempfile)
140
+ params[:image_src] = "/" + static_path
139
141
  end
140
142
 
141
- Item.create!(@library, params)
143
+ @library.create(params)
142
144
  end
143
145
 
144
146
  redirect to("/libraries/#{params[:kind]}")
@@ -149,14 +151,14 @@ module RobinCMS
149
151
 
150
152
  post "/libraries/:kind/item/delete" do
151
153
  if params[:id]
152
- @item = Item.find_one(@library, params[:id])
154
+ @item = @library.find_one(params[:id])
153
155
  halt 404 unless @item
154
156
 
155
157
  if @item.attributes[:image_src]
156
- StaticItem.delete_if_exists!(@library, @item.attributes[:image_src])
158
+ @library.delete_static(@item.attributes[:image_src])
157
159
  end
158
160
 
159
- @item.delete!
161
+ @library.delete(@item.id)
160
162
  else
161
163
  halt 404
162
164
  end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobinCMS
4
+ class CollectionLibrary < Library
5
+ def create(attributes)
6
+ id = make_slug(attributes[:title], @schema[:pattern])
7
+ item = Item.new(id, attributes, **@schema)
8
+
9
+ if File.exist?(filepath(id))
10
+ raise ItemExistsError, "An item with the same name already exists"
11
+ end
12
+
13
+ write(item)
14
+ end
15
+
16
+ def find_one(id)
17
+ path = filepath(id)
18
+
19
+ return unless File.exist?(path)
20
+
21
+ deserialize(path)
22
+ end
23
+
24
+ def all
25
+ Dir.glob(filepath("*"))
26
+ .filter { |f| File.file?(f) }
27
+ .map { |path| deserialize(path) }
28
+ end
29
+
30
+ def write(item)
31
+ path = filepath(item.id)
32
+
33
+ FileUtils.mkdir_p(File.dirname(path))
34
+ FileUtils.touch(path)
35
+
36
+ item.update_timestamps!
37
+
38
+ serialized = YAML.dump(item.frontmatter, stringify_names: true)
39
+ if item.attributes[:content]
40
+ serialized << "---\n"
41
+ serialized << item.attributes[:content]
42
+ end
43
+ File.write(path, serialized)
44
+
45
+ path
46
+ end
47
+
48
+ def delete(id)
49
+ File.delete(filepath(id))
50
+ end
51
+
52
+ private
53
+
54
+ def filepath(id)
55
+ File.join(@schema[:location], "#{id}.#{@schema[:filetype]}")
56
+ end
57
+
58
+ def deserialize(path)
59
+ raw = File.read(path)
60
+ id = File.basename(path, File.extname(path))
61
+
62
+ _, frontmatter, content = raw.split("---")
63
+
64
+ attributes = YAML.load(frontmatter, symbolize_names: true)
65
+ attributes[:content] = content.strip
66
+
67
+ Item.new(id, attributes, **@schema)
68
+ end
69
+ end
70
+ end
@@ -50,6 +50,7 @@
50
50
  "can_create": { "type": "boolean" },
51
51
  "can_delete": { "type": "boolean" },
52
52
  "pattern": { "type": "string" },
53
+ "display_name_pattern": { "type": "string" },
53
54
  "fields": {
54
55
  "type": "array",
55
56
  "items": {
@@ -89,6 +90,7 @@
89
90
  "can_create": { "type": "boolean" },
90
91
  "can_delete": { "type": "boolean" },
91
92
  "pattern": { "type": "string" },
93
+ "display_name_pattern": { "type": "string" },
92
94
  "fields": {
93
95
  "type": "array",
94
96
  "items": {
@@ -102,27 +102,13 @@ module RobinCMS
102
102
  end
103
103
 
104
104
  def find_library(kind)
105
- self[:libraries].find { |c| c[:id] == kind }
106
- end
107
-
108
- def sorted_fields(kind)
109
- find_library(kind)
110
- &.dig(:fields)
111
- &.sort_by { |field| field[:order] || Float::INFINITY }
112
- end
113
-
114
- def drafts_enabled?(library)
115
- collection?(library)
105
+ self[:libraries].find { |library| library[:id] == kind }
116
106
  end
117
107
 
118
108
  def collection?(library)
119
109
  library[:type].nil? || library[:type] == "collection"
120
110
  end
121
111
 
122
- def data?(library)
123
- !collection?(library)
124
- end
125
-
126
112
  def has_image?(library)
127
113
  library[:fields].find { |f| f[:type] == "image" }
128
114
  end
@@ -148,7 +134,7 @@ module RobinCMS
148
134
  config[:libraries].each do |library|
149
135
  library[:fields].append(*AUTOMATIC_FIELDS)
150
136
 
151
- if drafts_enabled?(library)
137
+ if collection?(library)
152
138
  library[:fields].append(*AUTOMATIC_DRAFT_FIELDS)
153
139
  end
154
140
 
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobinCMS
4
+ class DataLibrary < Library
5
+ def create(attributes)
6
+ item = Item.new(nil, attributes, **@schema)
7
+ write(item)
8
+ end
9
+
10
+ def find_one(id)
11
+ all[id.to_i]
12
+ end
13
+
14
+ def all
15
+ return [] unless File.exist?(filepath)
16
+
17
+ items = YAML.load_file(filepath, symbolize_names: true)
18
+
19
+ return [] unless items
20
+
21
+ items.each_with_index.map do |attrs, i|
22
+ Item.new(i, attrs, **@schema)
23
+ end
24
+ end
25
+
26
+ def write(item)
27
+ FileUtils.mkdir_p(File.dirname(filepath))
28
+ FileUtils.touch(filepath)
29
+
30
+ item.update_timestamps!
31
+
32
+ all_items = YAML.load_file(filepath) || []
33
+ if item.id.nil?
34
+ all_items.append(item.frontmatter)
35
+ else
36
+ all_items[item.id.to_i] = item.frontmatter
37
+ end
38
+
39
+ serialized = YAML.dump(all_items, stringify_names: true)
40
+ File.write(filepath, serialized)
41
+
42
+ filepath
43
+ end
44
+
45
+ def delete(id)
46
+ all_items = YAML.load_file(filepath)
47
+ all_items[id.to_i] = nil
48
+ serialized = YAML.dump(all_items.compact, stringify_names: true)
49
+ File.write(filepath, serialized)
50
+ end
51
+
52
+ private
53
+
54
+ def filepath
55
+ File.join(@schema[:location], "#{@schema[:id]}.#{@schema[:filetype]}")
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobinCMS
4
+ # This module provides methods for editing image files.
5
+ # No methods are required in the base class in order to use this mixin.
6
+ module Editable
7
+ def resize_image(filepath, dimensions)
8
+ # The mogrify command edits images in place. For more info, see
9
+ # mogrify(1).
10
+
11
+ system("mogrify -resize #{dimensions} #{filepath}")
12
+ if $?.exitstatus != 0
13
+ raise ConversionError, "Could not resize image #{filepath}"
14
+ end
15
+ end
16
+
17
+ def format_image(filepath, filetype)
18
+ system("mogrify -format #{filetype} #{filepath}")
19
+ if $?.exitstatus != 0
20
+ raise ConversionError, "Could not format image #{filepath}"
21
+ end
22
+
23
+ # The name of the converted file will be the same as the original but
24
+ # with a new file extension.
25
+ converted = filepath.sub(/#{File.extname(filepath)}$/, ".#{filetype}")
26
+
27
+ # Mogrify's format command creates a new file with the new extension.
28
+ # Copy the contents of this file to the original, then delete the newly
29
+ # created file.
30
+ IO.copy_stream(converted, filepath)
31
+ File.delete(converted)
32
+ end
33
+ end
34
+ end
@@ -1,34 +1,104 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RobinCMS
4
- # This is a wrapper class for providing a uniform interface to both the
5
- # DataItem class and the CollectionItem class. It's sole job is to dispatch
6
- # methods to the appropriate class based on the settings of the library.
7
4
  class Item
8
- METHOD_NAMES = %i[all where create! find_one blank]
5
+ attr_reader :id, :attributes
9
6
 
10
- def self.method_missing(method_name, *args, **kwargs)
11
- unless METHOD_NAMES.include?(method_name)
12
- super
13
- return
7
+ DATETIME_FORMAT = "%Y-%m-%d"
8
+
9
+ # The keys which we don't want to serialize.
10
+ FRONTMATTER_IGNORE_KEYS = [:id, :content, :image, :captures].freeze
11
+
12
+ def initialize(id, attrs = {}, **opts)
13
+ if !attrs.empty? && !attrs.has_key?(:title)
14
+ raise TypeError, "Missing required field `title'"
15
+ end
16
+
17
+ @id = id
18
+ @opts = opts
19
+
20
+ # Be sure to use the setter here so the keys get converted to symbols.
21
+ self.attributes = attrs
22
+ end
23
+
24
+ def attributes=(attributes)
25
+ @attributes = attributes.to_h.transform_keys(&:to_sym)
26
+
27
+ if attributes.has_key?(:published)
28
+ @attributes[:published] = attributes[:published].to_s == "true"
29
+ end
30
+ end
31
+
32
+ def inspect
33
+ "<#{self.class} id=\"#{id}\">"
34
+ end
35
+
36
+ def published?
37
+ if @attributes.has_key?(:published)
38
+ @attributes[:published]
39
+ else
40
+ true
41
+ end
42
+ end
43
+
44
+ def published_label
45
+ if published?
46
+ "Published"
47
+ else
48
+ "Draft"
14
49
  end
50
+ end
51
+
52
+ def created_at
53
+ return unless @attributes[:created_at]
54
+
55
+ Time.parse(@attributes[:created_at])
56
+ end
15
57
 
16
- # All the class methods for DataItem and CollectionItem should have
17
- # the library as the first argument.
18
- library = args.first
58
+ def updated_at
59
+ return unless @attributes[:updated_at]
19
60
 
20
- case library[:type]
21
- when "data"
22
- DataItem.send(method_name, *args, **kwargs)
23
- when "collection"
24
- CollectionItem.send(method_name, *args, **kwargs)
61
+ Time.parse(@attributes[:updated_at])
62
+ end
63
+
64
+ def update_timestamps!
65
+ timestamp = Time.now.strftime(DATETIME_FORMAT)
66
+
67
+ if !@attributes.has_key?(:created_at) || @attributes[:created_at].empty?
68
+ @attributes[:created_at] = timestamp
69
+ end
70
+ @attributes[:updated_at] = timestamp
71
+ end
72
+
73
+ def display_name
74
+ return @attributes[:title] unless @opts[:display_name_pattern]
75
+
76
+ @opts[:display_name_pattern].clone.tap do |name|
77
+ @attributes.each { |key, value| name.gsub!(":#{key}", value) }
78
+ end
79
+ end
80
+
81
+ # Get the frontmatter as a hash with stringified keys.
82
+ def frontmatter
83
+ frontmatter = @attributes.clone
84
+ FRONTMATTER_IGNORE_KEYS.each { |key| frontmatter.delete(key) }
85
+
86
+ if published?
87
+ frontmatter.delete(:published)
25
88
  else
26
- super
89
+ frontmatter[:published] = false
27
90
  end
91
+
92
+ # The Psych module (for which YAML is an alias) has a
93
+ # stringify_names option which does exactly this. However it was
94
+ # only introduced in Ruby 3.4. Using transform_keys is a workaround
95
+ # to support earlier versions of Ruby.
96
+ frontmatter.to_h.transform_keys(&:to_s)
28
97
  end
29
98
 
30
- def self.respond_to_missing?(method_name)
31
- METHOD_NAMES.include?(method_name)
99
+ def field_value_or_default(field)
100
+ value = @attributes[field[:id].to_sym] || field[:default]
101
+ value.to_s
32
102
  end
33
103
  end
34
104
  end